/*
 * 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.struts2.text;

import org.apache.commons.lang3.EnumUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionContext;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.DefaultOgnlCacheFactory;
import org.apache.struts2.ognl.OgnlCache;
import org.apache.struts2.ognl.OgnlCacheFactory.CacheType;
import org.apache.struts2.util.TextParseUtil;
import org.apache.struts2.util.ValueStack;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.MessageFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Objects;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;

abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {

    // Pinned to the value implicitly computed for the Struts 7.2.1 class shape, so sessions serialized by
    // an older node still deserialize here during a rolling upgrade. The caches that became transient are
    // simply discarded from such a stream and rebuilt by readObject.
    private static final long serialVersionUID = -4455624669971032217L;

    private static final Logger LOG = LogManager.getLogger(AbstractLocalizedTextProvider.class);

    public static final String XWORK_MESSAGES_BUNDLE = "org/apache/struts2/xwork-messages";
    public static final String STRUTS_MESSAGES_BUNDLE = "org/apache/struts2/struts-messages";

    private static final String TOMCAT_RESOURCE_ENTRIES_FIELD = "resourceEntries";
    private static final String TOMCAT_PARALLEL_WEBAPP_CLASSLOADER = "org.apache.catalina.loader.ParallelWebappClassLoader";
    private static final String TOMCAT_WEBAPP_CLASSLOADER = "org.apache.catalina.loader.WebappClassLoader";
    private static final String TOMCAT_WEBAPP_CLASSLOADER_BASE = "org.apache.catalina.loader.WebappClassLoaderBase";
    private static final String RELOADED = "org.apache.struts2.util.LocalizedTextProvider.reloaded";
    @SuppressWarnings("java:S2129") // deliberate: a non-interned instance is required for an identity (==) sentinel
    private static final String NOT_FOUND = new String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with ==

    protected boolean devMode = false;
    protected boolean reloadBundles = false;
    protected boolean searchDefaultBundlesFirst = false;  // Search default resource bundles first.  Note: This flag may not be meaningful to all implementations.

    private final ConcurrentMap<Integer, List<String>> classLoaderMap = new ConcurrentHashMap<>();
    private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap = new ConcurrentHashMap<>();

    // Dedicated monitor for bundlesMap-related synchronization: bundlesMap is reassigned by
    // rebuildI18nCaches(), so locking on it directly would lock on a monitor that can change identity.
    // transient + reinitialised in readObject: a bare Object is not Serializable.
    private transient Object bundlesMapLock = new Object();

    private static final int DEFAULT_I18N_CACHE_MAX_SIZE = 10000;

    private volatile CacheType i18nCacheType = CacheType.WTLFU;
    private volatile int i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;

    private <K, V> OgnlCache<K, V> buildI18nCache() {
        return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize, i18nCacheType).buildOgnlCache();
    }

    // The OgnlCache implementations are themselves thread-safe; volatile only safely publishes the
    // reference when rebuildI18nCaches() replaces a cache (during injection / readObject), so S3077
    // ("volatile is not enough") does not apply here.
    @SuppressWarnings("java:S3077")
    protected transient volatile OgnlCache<String, ResourceBundle> bundlesMap = buildI18nCache();
    @SuppressWarnings("java:S3077")
    private transient volatile OgnlCache<MessageFormatKey, MessageFormat> messageFormats = buildI18nCache();
    @SuppressWarnings("java:S3077")
    private transient volatile OgnlCache<String, Boolean> missingBundles = buildI18nCache();
    @SuppressWarnings("java:S3077")
    private transient volatile OgnlCache<TextCacheKey, String> classHierarchyCache = buildI18nCache();
    @SuppressWarnings("java:S3077")
    private transient volatile OgnlCache<TextCacheKey, String> packageHierarchyCache = buildI18nCache();

    @Override
    public void addDefaultResourceBundle(String bundleName) {
        //make sure this doesn't get added more than once
        final ClassLoader ccl = getCurrentThreadContextClassLoader();
        synchronized (XWORK_MESSAGES_BUNDLE) {
            List<String> bundles = classLoaderMap.computeIfAbsent(ccl.hashCode(), k -> new CopyOnWriteArrayList<>());
            bundles.remove(bundleName);
            bundles.add(0, bundleName);
        }

        if (LOG.isDebugEnabled()) {
            LOG.debug("Added default resource bundle '{}' to default resource bundles for the following classloader '{}'", bundleName, ccl.toString());
        }
    }

    protected List<String> getCurrentBundleNames() {
        return classLoaderMap.get(getCurrentThreadContextClassLoader().hashCode());
    }

    protected ClassLoader getCurrentThreadContextClassLoader() {
        return Thread.currentThread().getContextClassLoader();
    }

    private int currentLoaderHashCode() {
        // Identity-based on purpose: a custom ClassLoader overriding hashCode() must not be able to
        // collapse (or collide) the per-classloader cache partitions.
        return System.identityHashCode(getCurrentThreadContextClassLoader());
    }

    /** Test-support accessor: current number of cached class-hierarchy resolutions. */
    protected int classHierarchyCacheSize() {
        return classHierarchyCache.size();
    }

    /** Test-support accessor: current number of cached package-hierarchy resolutions. */
    protected int packageHierarchyCacheSize() {
        return packageHierarchyCache.size();
    }

    /** Test-support accessor: current number of cached resource bundles. */
    protected int bundlesMapSize() {
        return bundlesMap.size();
    }

    /** Test-support accessor: current number of cached missing-bundle markers. */
    protected int missingBundlesSize() {
        return missingBundles.size();
    }

    /** Test-support accessor: current number of cached message formats. */
    protected int messageFormatsSize() {
        return messageFormats.size();
    }

    @Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required = false)
    public void setCustomI18NResources(String bundles) {
        if (bundles == null || bundles.isEmpty()) {
            return;
        }
        StringTokenizer customBundles = new StringTokenizer(bundles, ", ");
        while (customBundles.hasMoreTokens()) {
            String name = customBundles.nextToken();
            try {
                LOG.trace("Loading global messages from [{}]", name);
                addDefaultResourceBundle(name);
            } catch (Exception e) {
                LOG.error("Could not find messages file {}.properties. Skipping", name, e);
            }
        }
    }

    @Override
    public String findDefaultText(String textKey, Locale locale) {
        List<String> localList = getCurrentBundleNames();

        for (String bundleName : localList) {
            ResourceBundle bundle = findResourceBundle(bundleName, locale);
            if (bundle != null) {
                reloadBundles();
                try {
                    return bundle.getString(textKey);
                } catch (MissingResourceException e) {
                    // will be logged when not found in any bundle
                }
            }
        }

        if (devMode) {
            LOG.warn("Missing key [{}] in bundles [{}]!", textKey, localList);
        } else {
            LOG.debug("Missing key [{}] in bundles [{}]!", textKey, localList);
        }

        return null;
    }

    @Override
    public String findDefaultText(String textKey, Locale locale, Object[] params) {
        String defaultText = findDefaultText(textKey, locale);
        if (defaultText != null) {
            MessageFormat mf = buildMessageFormat(defaultText, locale);
            return formatWithNullDetection(mf, params);
        }
        return null;
    }

    @Override
    public String findText(ResourceBundle bundle, String textKey, Locale locale, String defaultMessage, Object[] args,
                           ValueStack valueStack) {
        try {
            reloadBundles(valueStack.getContext());

            String message = TextParseUtil.translateVariables(bundle.getString(textKey), valueStack);
            MessageFormat mf = buildMessageFormat(message, locale);

            return formatWithNullDetection(mf, args);
        } catch (MissingResourceException ex) {
            if (devMode) {
                LOG.warn("Missing key [{}] in bundle [{}]!", textKey, bundle);
            } else {
                LOG.debug("Missing key [{}] in bundle [{}]!", textKey, bundle);
            }
        }

        GetDefaultMessageReturnArg result = getDefaultMessage(textKey, locale, valueStack, args, defaultMessage);
        if (unableToFindTextForKey(result)) {
            LOG.warn("Unable to find text for key '{}' in ResourceBundles for locale '{}'", textKey, locale);
        }
        return result != null ? result.message : null;
    }

    /**
     * @param classLoader a {@link ClassLoader} to look up the bundle from if none can be found on the current thread's classloader
     */
    public void setDelegatedClassLoader(final ClassLoader classLoader) {
        synchronized (bundlesMapLock) {
            delegatedClassLoaderMap.put(getCurrentThreadContextClassLoader().hashCode(), classLoader);
        }
    }

    /**
     * Clear a specific bundle + locale combination from the <code>bundlesMap</code>.
     * Intended for descendants to use clear a bundle + locale combination.
     *
     * @param bundleName The bundle (combined with locale) to remove from the bundle map
     * @param locale     Provides the locale to combine with the bundle to get the key
     * @since 6.0.0
     */
    protected void clearBundle(final String bundleName, Locale locale) {
        final String key = createMissesKey(String.valueOf(getCurrentThreadContextClassLoader().hashCode()), bundleName, locale);
        final ResourceBundle removedBundle = bundlesMap.remove(key);
        classHierarchyCache.clear();
        packageHierarchyCache.clear();
        LOG.debug("Clearing resource bundle [{}], locale [{}], result: [{}].", bundleName, locale, removedBundle != null);
    }

    /**
     * Clears the <code>missingBundles</code> contents.  This allows descendants to
     * clear the <b>"missing bundles cache"</b> when desired (or needed).
     * <p>
     * Note: This method may be used when the <code>bundlesMap</code> state has changed
     * in such a way that bundles that were previously "missing" may now be available
     * (e.g. after calling {@link #addDefaultResourceBundle(java.lang.String)} when the
     * {@link AbstractLocalizedTextProvider} has already been used for failed bundle
     * lookups of a given key, or some transitory state made a bundle lookup fail.
     *
     * @since 6.0.0
     */
    protected void clearMissingBundlesCache() {
        missingBundles.clear();
        classHierarchyCache.clear();
        packageHierarchyCache.clear();
        LOG.debug("Cleared the missing bundles cache.");
    }

    protected void reloadBundles() {
        reloadBundles(ActionContext.getContext() != null ? ActionContext.getContext().getContextMap() : null);
    }

    protected void reloadBundles(Map<String, Object> context) {
        if (reloadBundles) {
            try {
                Boolean reloaded;
                if (context != null) {
                    reloaded = (Boolean) ObjectUtils.defaultIfNull(context.get(RELOADED), Boolean.FALSE);
                } else {
                    reloaded = Boolean.FALSE;
                }
                if (!reloaded) {
                    bundlesMap.clear();
                    classHierarchyCache.clear();
                    packageHierarchyCache.clear();
                    clearResourceBundleClassloaderCaches();

                    // now, for the true and utter hack, if we're running in tomcat, clear
                    // it's class loader resource cache as well.
                    clearTomcatCache();
                    if (context != null) {
                        context.put(RELOADED, true);
                    }
                    LOG.debug("Resource bundles reloaded");
                }
            } catch (Exception e) {
                LOG.error("Could not reload resource bundles", e);
            }
        }
    }

    /**
     * A helper method for {@link ResourceBundle} bundle reload logic.
     * <p>
     * Uses standard {@link ResourceBundle} methods to clear the bundle caches for the
     * {@link ClassLoader} instances that this class is aware of at the time of the call.
     * <p>
     * The <code>clearCache()</code> methods have been available since Java 1.6, so
     * it is anticipated the logic will work on any subsequent JVM versions.
     *
     * @since 6.0.0
     */
    private void clearResourceBundleClassloaderCaches() {
        final ClassLoader ccl = getCurrentThreadContextClassLoader();
        ResourceBundle.clearCache();     // Bundles loaded by the caller's classloader.
        ResourceBundle.clearCache(ccl);  // Bundles loaded by the context classloader (may be the same).
        // Clear the bundle cache for any non-null delegated classloaders.
        delegatedClassLoaderMap.forEach((key, value) -> {
            if (value != null) ResourceBundle.clearCache(value);
        });
    }

    /**
     * "Hacky" helper method that attempts to clear the Tomcat <code>ResourceEntry</code>
     * {@link Map} using knowledge of the Tomcat source code.
     * <p>
     * It relies on the {@link #TOMCAT_RESOURCE_ENTRIES_FIELD} field name, base class name
     * {@link #TOMCAT_WEBAPP_CLASSLOADER_BASE}. and descendant class names {@link #TOMCAT_WEBAPP_CLASSLOADER},
     * {@link #TOMCAT_PARALLEL_WEBAPP_CLASSLOADER}, to keep the values identified in the constants.
     * It appears to be valid for Tomcat versions 7-10 so far, but could become invalid at any time in the future
     * when the resource handling logic in Tomcat changes.
     * <p>
     * Note: With Java 9+, calling this method may result in "Illegal reflective access" warnings.  Be aware
     * its logic may fail in a future version of Java that blocks the reflection calls needed for this method.
     */
    private void clearTomcatCache() {
        ClassLoader loader = getCurrentThreadContextClassLoader();
        // no need for compilation here.
        Class<?> cl = loader.getClass();
        Class<?> superCl = cl.getSuperclass();

        try {
            if ((TOMCAT_WEBAPP_CLASSLOADER.equals(cl.getName()) || TOMCAT_PARALLEL_WEBAPP_CLASSLOADER.equals(cl.getName())) &&
                    (superCl != null && TOMCAT_WEBAPP_CLASSLOADER_BASE.equals(superCl.getName()))) {
                // The classloader name and superclass name match the expecations for a Tomcat classloader.
                // Expect the classloader superclass to have the field, otherwise fallback to the classloader class if the field is not found.
                clearMap(superCl, loader, TOMCAT_RESOURCE_ENTRIES_FIELD);
                LOG.debug("Cleared tomcat cache via classloader's parent class.");
            } else {
                LOG.debug("Class loader {} is not tomcat loader.", cl.getName());
            }
        } catch (NoSuchFieldException nsfe) {
            LOG.debug("Parent class {} doesn't contain '{}' field, trying with base!", superCl.getName(), TOMCAT_RESOURCE_ENTRIES_FIELD, nsfe);
            try {
                clearMap(cl, loader, TOMCAT_RESOURCE_ENTRIES_FIELD);
                LOG.debug("Cleared tomcat cache via classloader's class.");
            } catch (Exception e) {
                LOG.warn("Couldn't clear tomcat cache using {}", cl.getName(), e);
            }
        } catch (Exception e) {
            LOG.warn("Couldn't clear tomcat cache using {}", (superCl != null ? superCl.getName() : null), e);
        }
    }

    /**
     * Helper method that is intended to clear a {@link Map} instance by name.
     * <p>
     * This method relies on reflection to perform its operations, and may be blocked in Java 9 and later,
     * depending on the accessibility of the field.
     *
     * @param cl   The {@link Class} of the obj parameter.
     * @param obj  The {@link Object} from which the named field is to be extracted (may be <code>null</code> for a static field).
     * @param name The name of the field containing a {@link Map} reference.
     * @throws NoSuchFieldException      if a field accessed by this call does not exist.
     * @throws IllegalAccessException    if a field, method or or class accessed by this call cannot be accessed.
     * @throws NoSuchMethodException     if a method accessed by this call does not exist.
     * @throws InvocationTargetException if a method accessed by this call fails invocation.
     */
    private void clearMap(Class<?> cl, Object obj, String name)
            throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {

        Field field = cl.getDeclaredField(name);

        if (!field.isAccessible()) {
            field.setAccessible(true);  // Change state only if necessary.
        }

        Object cache = field.get(obj);

        synchronized (cache) {
            Class<?> ccl = cache.getClass();
            Method clearMethod = ccl.getMethod("clear");
            clearMethod.invoke(cache);
        }
    }

    protected MessageFormat buildMessageFormat(String pattern, Locale locale) {
        MessageFormatKey key = new MessageFormatKey(pattern, locale);
        MessageFormat format = messageFormats.get(key);
        if (format == null) {
            format = new MessageFormat(pattern);
            format.setLocale(locale);
            format.applyPattern(pattern);
            messageFormats.put(key, format);
        }

        return format;
    }

    protected String formatWithNullDetection(MessageFormat mf, Object[] args) {
        String message = mf.format(args);
        if ("null".equals(message)) {
            return null;
        } else {
            return message;
        }
    }

    @Inject(value = StrutsConstants.STRUTS_I18N_RELOAD, required = false)
    public void setReloadBundles(String reloadBundles) {
        this.reloadBundles = Boolean.parseBoolean(reloadBundles);
    }

    @Inject(value = StrutsConstants.STRUTS_DEVMODE, required = false)
    public void setDevMode(String devMode) {
        this.devMode = Boolean.parseBoolean(devMode);
    }

    /**
     * Set the {@link #searchDefaultBundlesFirst} flag state.  This flag may be used by descendant TextProvider
     * implementations to determine if default bundles should be searched for messages first (before the standard
     * flow of the {@link LocalizedTextProvider} implementation the descendant provides).
     *
     * @param searchDefaultBundlesFirst provide {@link String} "true" or "false" to set the flag state accordingly.
     * @since 6.0.0
     */
    @Inject(value = StrutsConstants.STRUTS_I18N_SEARCH_DEFAULTBUNDLES_FIRST, required = false)
    public void setSearchDefaultBundlesFirst(String searchDefaultBundlesFirst) {
        this.searchDefaultBundlesFirst = Boolean.parseBoolean(searchDefaultBundlesFirst);
    }

    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
    public void setI18nCacheType(String cacheType) {
        this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class, cacheType, CacheType.WTLFU);
        rebuildI18nCaches();
    }

    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required = false)
    public void setI18nCacheMaxSize(String cacheMaxSize) {
        this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
        rebuildI18nCaches();
    }

    /**
     * Rebuilds the localized-text caches from the current type/size. Called during dependency injection
     * (single-threaded startup, before the provider serves lookups); discards any warm-up entries.
     */
    private void rebuildI18nCaches() {
        bundlesMap = buildI18nCache();
        messageFormats = buildI18nCache();
        missingBundles = buildI18nCache();
        classHierarchyCache = buildI18nCache();
        packageHierarchyCache = buildI18nCache();
    }

    private void readObject(java.io.ObjectInputStream in) throws java.io.IOException, ClassNotFoundException {
        in.defaultReadObject();
        bundlesMapLock = new Object();
        // Field initialisers do not run during deserialization, so a stream written before these settings
        // existed (an older node in a rolling upgrade) leaves them at null/0. Restore the defaults.
        if (i18nCacheType == null) {
            i18nCacheType = CacheType.WTLFU;
        }
        if (i18nCacheMaxSize <= 0) {
            i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
        }
        rebuildI18nCaches();
    }

    @Override
    public ResourceBundle findResourceBundle(String bundleName, Locale locale) {
        ClassLoader classLoader = getCurrentThreadContextClassLoader();
        String key = createMissesKey(String.valueOf(classLoader.hashCode()), bundleName, locale);

        if (missingBundles.get(key) != null) {
            return null;
        }

        ResourceBundle bundle = null;
        try {
            bundle = bundlesMap.get(key);
            if (bundle == null) {
                bundle = ResourceBundle.getBundle(bundleName, locale, classLoader);
                bundlesMap.putIfAbsent(key, bundle);
            }
        } catch (MissingResourceException ex) {
            if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
                try {
                    bundle = bundlesMap.get(key);
                    if (bundle == null) {
                        bundle = ResourceBundle.getBundle(bundleName, locale, delegatedClassLoaderMap.get(classLoader.hashCode()));
                        bundlesMap.putIfAbsent(key, bundle);
                    }
                } catch (MissingResourceException e) {
                    LOG.debug("Missing resource bundle [{}]!", bundleName, e);
                    missingBundles.put(key, Boolean.TRUE);
                }
            } else {
                LOG.debug("Missing resource bundle [{}]!", bundleName);
                missingBundles.put(key, Boolean.TRUE);
            }
        }
        return bundle;
    }

    /**
     * Determines if we found the text in the bundles.
     *
     * @param result the result so far
     * @return <tt>true</tt> if we could <b>not</b> find the text, <tt>false</tt> if the text was found (=success).
     */
    protected boolean unableToFindTextForKey(GetDefaultMessageReturnArg result) {
        if (result == null || result.message == null) {
            return true;
        }

        // did we find it in the bundle, then no problem?
        return !result.foundInBundle;
    }

    /**
     * Creates a key to used for lookup/storing in the bundle misses cache.
     *
     * @param prefix      the prefix for the returning String - it is supposed to be the ClassLoader hash code.
     * @param aBundleName the name of the bundle (usually it's FQN classname).
     * @param locale      the locale.
     * @return the key to use for lookup/storing in the bundle misses cache.
     */
    private String createMissesKey(String prefix, String aBundleName, Locale locale) {
        return prefix + aBundleName + "_" + locale.toString();
    }

    /**
     * @return the default message.
     */
    protected GetDefaultMessageReturnArg getDefaultMessage(String key, Locale locale, ValueStack valueStack, Object[] args,
                                                           String defaultMessage) {
        GetDefaultMessageReturnArg result = null;
        boolean found = true;

        if (key != null) {
            String message = findDefaultText(key, locale);

            if (message == null) {
                message = defaultMessage;
                found = false; // not found in bundles
            }

            // defaultMessage may be null
            if (message != null) {
                MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);

                String msg = formatWithNullDetection(mf, args);
                result = new GetDefaultMessageReturnArg(msg, found);
            }
        }

        return result;
    }

    /**
     * A helper method that can be used by descendant classes to perform some common two-stage message lookup operations
     * against the default resource bundles.  The default resource bundles are searched for a value using key first, then
     * alternateKey when the first search fails, then utilizing defaultMessage (which may be <code>null</code>) if <em>both</em>
     * key lookup operations fail.
     *
     * <p>
     * A known use case is when a key indexes a collection (e.g. user.phone[0]) for which some specific keys may exist, but not all,
     * along with a general key (e.g. user.phone[*]).  In such cases the specific key would be passed in the key parameter and the
     * general key would be passed in the alternateKey parameter.
     * </p>
     *
     * @param key            the initial key to search for a value within the default resource bundles.
     * @param alternateKey   the alternate (fall-back) key to search for a value within the default resource bundles, if the initial key lookup fails.
     * @param locale         the {@link Locale} to be used for the default resource bundle lookup.
     * @param valueStack     the {@link ValueStack} associated with the operation.
     * @param args           the argument array for parameterized messages (may be <code>null</code>).
     * @param defaultMessage the default message {@link String} to use if both key lookup operations fail.
     * @return the {@link GetDefaultMessageReturnArg} result containing the processed message lookup (by key first, then alternateKey if key's lookup fails).
     * If both key lookup operations fail, defaultMessage is used for processing.
     * If defaultMessage is <code>null</code> then the return result may be <code>null</code>.
     */
    protected GetDefaultMessageReturnArg getDefaultMessageWithAlternateKey(String key, String alternateKey, Locale locale, ValueStack valueStack,
                                                                           Object[] args, String defaultMessage) {
        GetDefaultMessageReturnArg result;
        if (alternateKey == null || alternateKey.isEmpty()) {
            result = getDefaultMessage(key, locale, valueStack, args, defaultMessage);
        } else {
            result = getDefaultMessage(key, locale, valueStack, args, null);
            if (result == null || result.message == null) {
                result = getDefaultMessage(alternateKey, locale, valueStack, args, defaultMessage);
            }
        }
        return result;
    }

    /**
     * Resolves the raw (untranslated, unformatted) message pattern for a key within a single bundle.
     * Returns {@code null} when the bundle or key is absent. This is the cacheable unit relied upon by
     * the hierarchy-resolution caches; translation and formatting are applied separately by
     * {@link #formatMessage(String, Locale, ValueStack, Object[])}.
     */
    private String getRawMessage(String bundleName, Locale locale, String key) {
        ResourceBundle bundle = findResourceBundle(bundleName, locale);
        if (bundle == null) {
            return null;
        }
        try {
            return bundle.getString(key);
        } catch (MissingResourceException e) {
            LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
            return null;
        }
    }

    /**
     * Applies value stack variable translation (when a stack is available) and {@link MessageFormat}
     * argument substitution to a raw message pattern. Mirrors the rendering previously performed inline
     * by {@link #getMessage(String, Locale, String, ValueStack, Object[])}.
     */
    protected String formatMessage(String rawPattern, Locale locale, ValueStack valueStack, Object[] args) {
        String message = (valueStack != null)
                ? TextParseUtil.translateVariables(rawPattern, valueStack)
                : rawPattern;
        MessageFormat mf = buildMessageFormat(message, locale);
        return formatWithNullDetection(mf, args);
    }

    /**
     * @return the message from the named resource bundle.
     * @deprecated since 7.3.0 — superseded by the internal raw-resolution + caching path
     * ({@link #formatMessage(String, Locale, ValueStack, Object[])} over a raw lookup). Retained for
     * backward compatibility with descendant classes that call it directly. <strong>No longer invoked
     * by {@code findText}</strong>: overriding this method does not affect framework message lookup
     * anymore; override {@link #formatMessage(String, Locale, ValueStack, Object[])} to customize
     * rendering instead. Scheduled for removal in the next major release (see WW-5658).
     */
    @Deprecated(since = "7.3.0", forRemoval = true)
    protected String getMessage(String bundleName, Locale locale, String key, ValueStack valueStack, Object[] args) {
        ResourceBundle bundle = findResourceBundle(bundleName, locale);
        if (bundle == null) {
            return null;
        }
        if (valueStack != null) {
            reloadBundles(valueStack.getContext());
        }
        try {
            String rawPattern = bundle.getString(key);
            return formatMessage(rawPattern, locale, valueStack, args);
        } catch (MissingResourceException e) {
            LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
            return null;
        }
    }

    /**
     * Raw-pattern twin of {@link #findMessage}. Walks class, implemented interfaces, then up the
     * hierarchy, returning the first raw message pattern found (via {@link #getRawMessage}) without
     * translation or formatting. Used by the cached class-hierarchy resolver.
     */
    private String findMessageRaw(Class<?> clazz, String key, String indexedKey, Locale locale, Set<String> checked) {
        if (checked == null) {
            checked = new TreeSet<>();
        } else if (checked.contains(clazz.getName())) {
            return null;
        }
        // Record this class so diamond-shaped interface hierarchies aren't re-traversed.
        checked.add(clazz.getName());

        // look in properties of this class
        String msg = getRawMessageWithAlternate(clazz.getName(), locale, key, indexedKey);
        if (msg != null) {
            return msg;
        }

        // look in properties of implemented interfaces
        for (Class<?> anInterface : clazz.getInterfaces()) {
            msg = getRawMessageWithAlternate(anInterface.getName(), locale, key, indexedKey);
            if (msg != null) {
                return msg;
            }
        }

        // traverse up hierarchy
        if (clazz.isInterface()) {
            for (Class<?> anInterface : clazz.getInterfaces()) {
                msg = findMessageRaw(anInterface, key, indexedKey, locale, checked);
                if (msg != null) {
                    return msg;
                }
            }
        } else if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
            return findMessageRaw(clazz.getSuperclass(), key, indexedKey, locale, checked);
        }

        return null;
    }

    /**
     * Resolves the raw message pattern for a key within a single bundle, falling back to the
     * indexed (general-form) key when the primary key is absent.
     */
    private String getRawMessageWithAlternate(String bundleName, Locale locale, String key, String indexedKey) {
        String msg = getRawMessage(bundleName, locale, key);
        if (msg == null && indexedKey != null) {
            msg = getRawMessage(bundleName, locale, indexedKey);
        }
        return msg;
    }

    /**
     * Cached resolution of the class/interface/superclass hierarchy for a key. Returns the raw pattern
     * found, or {@link #NOT_FOUND} when the key is absent from the entire hierarchy. Keyed on the
     * context classloader hash + class name + key + locale, so no {@link Class} reference is retained.
     * Uses get + putIfAbsent (never computeIfAbsent) because the child-property path recurses into findText.
     */
    String resolveClassHierarchyRaw(Class<?> clazz, String textKey, Locale locale) {
        TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), clazz.getName(), textKey, locale);
        String cached = classHierarchyCache.get(cacheKey);
        if (cached != null) {
            return cached;
        }
        // Derived here (miss-only) rather than accepted as a parameter, so the cache key trivially
        // covers every input that influences the resolution result.
        String raw = findMessageRaw(clazz, textKey, extractIndexedName(textKey), locale, null);
        String toStore = (raw != null) ? raw : NOT_FOUND;
        classHierarchyCache.putIfAbsent(cacheKey, toStore);
        return toStore;
    }

    /** @return true when a cached raw-resolution result represents "not found". */
    @SuppressWarnings("java:S4973") // deliberate identity comparison against the non-interned NOT_FOUND sentinel
    protected boolean isNotFound(String cachedRawResult) {
        return cachedRawResult == NOT_FOUND;
    }

    /**
     * Raw-pattern walk of the {@code *.package} bundles up the class hierarchy of {@code startClazz}.
     * Returns the first raw pattern found (via {@link #getRawMessage}) for the key or its indexed form,
     * or {@code null} when none match.
     */
    private String findPackageMessageRaw(Class<?> startClazz, String textKey, String indexedTextName, Locale locale) {
        for (Class<?> clazz = startClazz;
             (clazz != null) && !clazz.equals(Object.class);
             clazz = clazz.getSuperclass()) {

            String basePackageName = clazz.getName();
            while (basePackageName.lastIndexOf('.') != -1) {
                basePackageName = basePackageName.substring(0, basePackageName.lastIndexOf('.'));
                String packageName = basePackageName + ".package";
                String msg = getRawMessageWithAlternate(packageName, locale, textKey, indexedTextName);
                if (msg != null) {
                    return msg;
                }
            }
        }
        return null;
    }

    /**
     * Cached resolution of the {@code *.package} hierarchy for a key. Returns the raw pattern found, or
     * {@link #NOT_FOUND} when absent. Same keying and get + putIfAbsent discipline as
     * {@link #resolveClassHierarchyRaw}.
     */
    String resolvePackageHierarchyRaw(Class<?> startClazz, String textKey, Locale locale) {
        TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), startClazz.getName(), textKey, locale);
        String cached = packageHierarchyCache.get(cacheKey);
        if (cached != null) {
            return cached;
        }
        // Derived here (miss-only) rather than accepted as a parameter, so the cache key trivially
        // covers every input that influences the resolution result.
        String raw = findPackageMessageRaw(startClazz, textKey, extractIndexedName(textKey), locale);
        String toStore = (raw != null) ? raw : NOT_FOUND;
        packageHierarchyCache.putIfAbsent(cacheKey, toStore);
        return toStore;
    }

    /**
     * Traverse up class hierarchy looking for message.  Looks at class, then implemented interface,
     * before going up hierarchy.
     *
     * @return the message
     * @deprecated since 7.3.0 — superseded by the internal raw-resolution + caching path
     * ({@link #findMessageRaw} + {@link #formatMessage(String, Locale, ValueStack, Object[])}). Retained
     * for backward compatibility with descendant classes that call it directly. <strong>No longer
     * invoked by {@code findText}</strong>: overriding this method does not affect framework message
     * lookup anymore; override {@link #formatMessage(String, Locale, ValueStack, Object[])} to
     * customize rendering instead. Note: unlike the pre-7.3.0 implementation, a
     * candidate whose formatted value is the literal {@code "null"} no longer causes the search to
     * continue deeper in the same hierarchy; this affects only the pathological case of the same key
     * redefined at multiple hierarchy levels with the shallow value formatting to {@code "null"}.
     * The bundle-reload check is now triggered once on entry (when reload mode is enabled) rather than
     * lazily per bundle probe, preserving the reload side effect that the previous getMessage-per-probe
     * walk provided. Scheduled for removal in the next major release (see WW-5658).
     */
    @Deprecated(since = "7.3.0", forRemoval = true)
    protected String findMessage(Class<?> clazz, String key, String indexedKey, Locale locale, Object[] args, Set<String> checked,
                                 ValueStack valueStack) {
        if (valueStack != null) {
            reloadBundles(valueStack.getContext());
        } else {
            reloadBundles();
        }
        String rawPattern = findMessageRaw(clazz, key, indexedKey, locale, checked);
        return rawPattern != null ? formatMessage(rawPattern, locale, valueStack, args) : null;
    }

    protected String extractIndexedName(String textKey) {
        String indexedTextName = null;
        // calculate indexedTextName (collection[*]) if applicable
        if (textKey.contains("[")) {
            int i = -1;

            indexedTextName = textKey;

            while ((i = indexedTextName.indexOf('[', i + 1)) != -1) {
                int j = indexedTextName.indexOf(']', i);
                String a = indexedTextName.substring(0, i);
                String b = indexedTextName.substring(j);
                indexedTextName = a + "[*" + b;
            }
        }
        return indexedTextName;
    }

    protected void logMissingText(Class<?> startClazz, String textKey, Locale locale, GetDefaultMessageReturnArg result, String indexedTextName) {
        // could we find the text, if not log a WARN
        if (unableToFindTextForKey(result) && LOG.isDebugEnabled()) {
            String warn = "Unable to find text for key '" + textKey + "' ";
            if (indexedTextName != null) {
                warn += " or indexed key '" + indexedTextName + "' ";
            }
            warn += "in class '" + startClazz.getName() + "' and locale '" + locale + "'";
            LOG.debug(warn);
        }
    }

    static class MessageFormatKey {
        String pattern;
        Locale locale;

        MessageFormatKey(String pattern, Locale locale) {
            this.pattern = pattern;
            this.locale = locale;
        }


        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            MessageFormatKey that = (MessageFormatKey) o;

            if (!Objects.equals(pattern, that.pattern)) return false;
            return Objects.equals(locale, that.locale);
        }

        @Override
        public int hashCode() {
            int result = pattern != null ? pattern.hashCode() : 0;
            result = 31 * result + (locale != null ? locale.hashCode() : 0);
            return result;
        }
    }

    static class TextCacheKey {
        private final int classLoaderHash;
        private final String className;
        private final String textKey;
        private final Locale locale;

        TextCacheKey(int classLoaderHash, String className, String textKey, Locale locale) {
            this.classLoaderHash = classLoaderHash;
            this.className = className;
            this.textKey = textKey;
            this.locale = locale;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            TextCacheKey that = (TextCacheKey) o;
            return classLoaderHash == that.classLoaderHash
                    && Objects.equals(className, that.className)
                    && Objects.equals(textKey, that.textKey)
                    && Objects.equals(locale, that.locale);
        }

        @Override
        public int hashCode() {
            int result = classLoaderHash;
            result = 31 * result + (className != null ? className.hashCode() : 0);
            result = 31 * result + (textKey != null ? textKey.hashCode() : 0);
            result = 31 * result + (locale != null ? locale.hashCode() : 0);
            return result;
        }
    }

    static class GetDefaultMessageReturnArg {
        String message;
        boolean foundInBundle;

        public GetDefaultMessageReturnArg(String message, boolean foundInBundle) {
            this.message = message;
            this.foundInBundle = foundInBundle;
        }
    }

}
