/*******************************************************************************
 * 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.ofbiz.base.util;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.InvalidPropertiesFormatException;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Properties;
import java.util.ResourceBundle;
import java.util.Set;

import org.apache.ofbiz.base.location.FlexibleLocation;
import org.apache.ofbiz.base.util.cache.UtilCache;
import org.apache.ofbiz.base.util.collections.ResourceBundleMapWrapper;
import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.util.EntityUtilProperties;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

/** Generic Property Accessor with Cache - Utilities for working with properties files.
 * <p>UtilProperties divides properties files into two classes: non-locale-specific -
 * which are used for application parameters, configuration settings, etc; and
 * locale-specific - which are used for UI labels, system messages, etc. Each class
 * of properties files is kept in its own cache.</p>
 * <p>The locale-specific class of properties files can be in any one of three
 * formats: the standard text-based key=value format (*.properties file), the Java
 * XML properties format, and the OFBiz-specific XML file format
 * (see the <a href="#xmlToProperties(java.io.InputStream,%20java.util.Locale,%20java.util.Properties)">xmlToProperties</a>
 * method).</p>
 */
@SuppressWarnings("serial")
public final class UtilProperties implements Serializable {

    private static final String MODULE = UtilProperties.class.getName();

    private UtilProperties() { }

    /**
     * A cache for storing Properties instances. Each Properties instance is keyed by its URL.
     */
    private static final UtilCache<String, Properties> URL_CACHE = UtilCache.createUtilCache("properties.UtilPropertiesUrlCache");

    private static final Set<String> PROP_NOT_FOUND = new HashSet<>();

    /** Compares the specified property to the compareString, returns true if they are the same, false otherwise
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param compareString The String to compare the property value to
     * @return True if the strings are the same, false otherwise
     */
    public static boolean propertyValueEquals(String resource, String name, String compareString) {
        String value = getPropertyValue(resource, name);

        return value.trim().equals(compareString);
    }

    /** Compares Ignoring Case the specified property to the compareString, returns true if they are the same, false otherwise
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param compareString The String to compare the property value to
     * @return True if the strings are the same, false otherwise
     */
    public static boolean propertyValueEqualsIgnoreCase(String resource, String name, String compareString) {
        String value = getPropertyValue(resource, name);

        return value.trim().equalsIgnoreCase(compareString);
    }

    /** Returns the value of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultValue is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultValue The value to return if the property is not found
     * @return The value of the property in the properties file, or if not found then the defaultValue
     */
    public static String getPropertyValue(String resource, String name, String defaultValue) {
        String value = getPropertyValue(resource, name);

        if (UtilValidate.isEmpty(value)) {
            return defaultValue;
        }
        return value;
    }

    public static double getPropertyNumber(String resource, String name, double defaultValue) {
        String str = getPropertyValue(resource, name);

        try {
            return Double.parseDouble(str);
        } catch (NumberFormatException nfe) {
            return defaultValue;
        }
    }

    public static double getPropertyNumber(String resource, String name) {
        return getPropertyNumber(resource, name, 0.00000);
    }

    /**
     * Returns the Number as a Number-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultObject is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Number to return if the property is not found.
     * @param type A String of the the Object the Number is converted to (like "Integer").
     * @return A Number-Object of the property as the defined type; or if not found the defaultObject
     */
    private static Number getPropertyNumber(String resource, String name, Number defaultNumber, String type) {
        String str = getPropertyValue(resource, name);
        if (UtilValidate.isEmpty(str)) {
            if (Debug.verboseOn()) {
                Debug.logVerbose("The property " + resource + ":" + name + " is empty, using defaultNumber " + defaultNumber + ".", MODULE);
            }
            return defaultNumber;
        }
        try {
            return (Number) (ObjectType.simpleTypeOrObjectConvert(str, type, null, null));
        } catch (GeneralException e) {
            Debug.logWarning("Error converting String \"" + str + "\" to " + type + "; using defaultNumber " + defaultNumber + ".", MODULE);
        }
        return defaultNumber;
    }

    /**
     * Returns a Boolean-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultValue is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultValue Optional: The Value to return if the property is not found or not the correct format.
     * @return A Boolean-Object of the property; or if not found the defaultValue
     */
    public static Boolean getPropertyAsBoolean(String resource, String name, boolean defaultValue) {
        String str = getPropertyValue(resource, name);
        if ("true".equalsIgnoreCase(str)) {
            return Boolean.TRUE;
        } else if ("false".equalsIgnoreCase(str)) {
            return Boolean.FALSE;
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns an Integer-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return An Integer-Object of the property; or if not found the defaultNumber
     */
    public static Integer getPropertyAsInteger(String resource, String name, int defaultNumber) {
        return (Integer) getPropertyNumber(resource, name, defaultNumber, "Integer");
    }

    /**
     * Returns a Long-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return A Long-Object of the property; or if not found the defaultNumber
     */
    public static Long getPropertyAsLong(String resource, String name, long defaultNumber) {
        return (Long) getPropertyNumber(resource, name, defaultNumber, "Long");
    }

    /**
     * Returns a Float-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return A Long-Object of the property; or if not found the defaultNumber
     */
    public static Float getPropertyAsFloat(String resource, String name, float defaultNumber) {
        return (Float) getPropertyNumber(resource, name, defaultNumber, "Float");
    }

    /**
     * Returns a Double-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return A Double-Object of the property; or if not found the defaultNumber
     */
    public static Double getPropertyAsDouble(String resource, String name, double defaultNumber) {
        return (Double) getPropertyNumber(resource, name, defaultNumber, "Double");
    }

    /**
     * Returns a BigInteger-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return A BigInteger-Object of the property; or if not found the defaultNumber
     */
    public static BigInteger getPropertyAsBigInteger(String resource, String name, BigInteger defaultNumber) {
        String strValue = getPropertyValue(resource, name);
        BigInteger result = defaultNumber;
        try {
            result = new BigInteger(strValue);
        } catch (NumberFormatException nfe) {
            Debug.logWarning("Couldnt convert String \"" + strValue + "\" to BigInteger; using defaultNumber "
                    + defaultNumber.toString() + ".", MODULE);
        }
        return result;
    }

    /**
     * Returns a BigDecimal-Object of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultNumber is returned.
     * @param resource The name of the resource - if the properties file is 'webevent.properties', the resource name is 'webevent'
     * @param name The name of the property in the properties file
     * @param defaultNumber Optional: The Value to return if the property is not found.
     * @return A BigDecimal-Object of the property; or if not found the defaultNumber
     */
    public static BigDecimal getPropertyAsBigDecimal(String resource, String name, BigDecimal defaultNumber) {
        String strValue = getPropertyValue(resource, name);
        BigDecimal result = defaultNumber;
        try {
            result = new BigDecimal(strValue);
        } catch (NumberFormatException nfe) {
            Debug.logWarning("Couldnt convert String \"" + strValue + "\" to BigDecimal; using defaultNumber "
                    + defaultNumber.toString() + ".", MODULE);
        }
        return result;
    }

    /** Returns the value of the specified property name from the specified resource/properties file
     * @param resource The name of the resource - can be a file, class, or URL
     * @param name The name of the property in the properties file
     * @return The value of the property in the properties file
     */
    public static String getPropertyValue(String resource, String name) {
        if (UtilValidate.isEmpty(resource)) {
            return "";
        }
        if (UtilValidate.isEmpty(name)) {
            return "";
        }

        Properties properties = getProperties(resource);
        if (properties == null) {
            return "";
        }

        String value = null;

        try {
            value = properties.getProperty(name);
        } catch (Exception e) {
            Debug.logInfo(e, MODULE);
        }
        return value == null ? "" : value.trim();
    }

    /**
     * Returns a new <code>Properties</code> instance created from <code>fileName</code>.
     * <p>This method is intended for low-level framework classes that need to read
     * properties files before OFBiz has been fully initialized.</p>
     * @param fileName The full name of the properties file ("foo.properties")
     * @return A new <code>Properties</code> instance created from <code>fileName</code>, or
     * <code>null</code> if the file was not found
     * @throws IllegalArgumentException if <code>fileName</code> is empty
     * @throws IllegalStateException if there was a problem reading the file
     */
    public static Properties createProperties(String fileName) {
        Assert.notEmpty("fileName", fileName);
        InputStream inStream = null;
        try {
            URL url = Thread.currentThread().getContextClassLoader().getResource(fileName);
            if (url == null) {
                return null;
            }
            inStream = url.openStream();
            Properties properties = new Properties();
            properties.load(inStream);
            return properties;
        } catch (Exception e) {
            throw new IllegalStateException("Exception thrown while reading " + fileName + ": " + e);
        } finally {
            if (inStream != null) {
                try {
                    inStream.close();
                } catch (IOException e) {
                    Debug.logError(e, "Exception thrown while closing InputStream", MODULE);
                }
            }
        }
    }

    /** Returns the specified resource/properties file
     * @param resource The name of the resource - can be a file, class, or URL
     * @return The properties file
     */
    public static Properties getProperties(String resource) {
        if (UtilValidate.isEmpty(resource)) {
            return null;
        }
        URL url = resolvePropertiesUrl(resource, null);
        return getProperties(url);
    }

    /** Returns the specified resource/properties file
     * @param url The URL to the resource
     * @return The properties file
     */
    public static Properties getProperties(URL url) {
        if (url == null) {
            return null;
        }
        String cacheKey = url.toString();
        Properties properties = URL_CACHE.get(cacheKey);
        if (properties == null) {
            try {
                properties = new ExtendedProperties(url, null);
                URL_CACHE.put(cacheKey, properties);
            } catch (Exception e) {
                Debug.logInfo(e, MODULE);
            }
        }
        if (properties == null) {
            Debug.logInfo("[UtilProperties.getProperties] could not find resource: " + url, MODULE);
            return null;
        }
        return properties;
    }


    // ========= URL Based Methods ==========

    /** Compares the specified property to the compareString, returns true if they are the same, false otherwise
     * @param url URL object specifying the location of the resource
     * @param name The name of the property in the properties file
     * @param compareString The String to compare the property value to
     * @return True if the strings are the same, false otherwise
     */
    public static boolean propertyValueEquals(URL url, String name, String compareString) {
        String value = getPropertyValue(url, name);
        return !(value == null) && value.trim().equals(compareString);
    }

    /** Compares Ignoring Case the specified property to the compareString, returns true if they are the same, false otherwise
     * @param url URL object specifying the location of the resource
     * @param name The name of the property in the properties file
     * @param compareString The String to compare the property value to
     * @return True if the strings are the same, false otherwise
     */
    public static boolean propertyValueEqualsIgnoreCase(URL url, String name, String compareString) {
        String value = getPropertyValue(url, name);
        return !(value == null) && value.trim().equalsIgnoreCase(compareString);
    }

    /** Returns the value of the specified property name from the specified resource/properties file.
     * If the specified property name or properties file is not found, the defaultValue is returned.
     * @param url URL object specifying the location of the resource
     * @param name The name of the property in the properties file
     * @param defaultValue The value to return if the property is not found
     * @return The value of the property in the properties file, or if not found then the defaultValue
     */
    public static String getPropertyValue(URL url, String name, String defaultValue) {
        String value = getPropertyValue(url, name);

        if (UtilValidate.isEmpty(value)) {
            return defaultValue;
        }
        return value;
    }

    public static double getPropertyNumber(URL url, String name, double defaultValue) {
        String str = getPropertyValue(url, name);
        if (str == null) {
            return defaultValue;
        }

        try {
            return Double.parseDouble(str);
        } catch (NumberFormatException nfe) {
            return defaultValue;
        }
    }

    public static double getPropertyNumber(URL url, String name) {
        return getPropertyNumber(url, name, 0.00000);
    }

    /** Returns the value of the specified property name from the specified resource/properties file
     * @param url URL object specifying the location of the resource
     * @param name The name of the property in the properties file
     * @return The value of the property in the properties file
     */
    public static String getPropertyValue(URL url, String name) {
        if (url == null) {
            return "";
        }
        if (UtilValidate.isEmpty(name)) {
            return "";
        }
        Properties properties = getProperties(url);

        if (properties == null) {
            return null;
        }

        String value = null;

        try {
            value = properties.getProperty(name);
        } catch (Exception e) {
            Debug.logInfo(e, MODULE);
        }
        return value == null ? "" : value.trim();
    }

    /** Returns the value of a split property name from the specified resource/properties file
     * Rather than specifying the property name the value of a name.X property is specified which
     * will correspond to a value.X property whose value will be returned. X is a number from 1 to
     * whatever and all values are checked until a name.X for a certain X is not found.
     * @param url URL object specifying the location of the resource
     * @param name The name of the split property in the properties file
     * @return The value of the split property from the properties file
     */
    public static String getSplitPropertyValue(URL url, String name) {
        if (url == null) {
            return "";
        }
        if (UtilValidate.isEmpty(name)) {
            return "";
        }

        Properties properties = getProperties(url);

        if (properties == null) {
            return null;
        }

        String value = null;

        try {
            int curIdx = 1;
            String curName = null;

            while ((curName = properties.getProperty("name." + curIdx)) != null) {
                if (name.equals(curName)) {
                    value = properties.getProperty("value." + curIdx);
                    break;
                }
                curIdx++;
            }
        } catch (Exception e) {
            Debug.logInfo(e, MODULE);
        }
        return value == null ? "" : value.trim();
    }

    /**
     * Sets the specified value of the specified property name to the specified resource/properties in memory, does not persist it
     * @param resource The name of the resource
     * @param name     The name of the property in the resource
     * @param value    The value of the property to set in memory
     */
    public static void setPropertyValueInMemory(String resource, String name, String value) {
        if (UtilValidate.isEmpty(resource)) {
            return;
        }
        if (UtilValidate.isEmpty(name)) {
            return;
        }

        Properties properties = UtilProperties.getProperties(resource);
        if (properties == null) {
            return;
        }
        properties.setProperty(name, value);
    }

    /**
     * Sets the specified value of the specified property name to the specified resource/properties in memory, does not persist it
     * @param delegator Default delegator, mostly used in tests
     * @param resource The name of the resource
     * @param name     The name of the property in the resource
     * @param value    The value of the property to set in memory
     */
    public static void setPropertyValueInMemory(Delegator delegator, String resource, String name, String value) {
        if (UtilValidate.isEmpty(resource)) {
            return;
        }
        if (UtilValidate.isEmpty(name)) {
            return;
        }

        Properties properties = EntityUtilProperties.getProperties(delegator, resource);
        if (properties == null) {
            return;
        }
        properties.setProperty(name, value);
    }

    // ========= Locale & Resource Based Methods ==========

    /** Returns the value of the specified property name from the specified
     *  resource/properties file corresponding to the given locale.
     * @param resource The name of the resource - can be a file, class, or URL
     * @param name The name of the property in the properties file
     * @param locale The locale that the given resource will correspond to
     * @return The value of the property in the properties file
     */
    public static String getMessage(String resource, String name, Locale locale) {
        if (UtilValidate.isEmpty(resource)) {
            return "";
        }
        if (UtilValidate.isEmpty(name)) {
            return "";
        }

        ResourceBundle bundle = getResourceBundle(resource, locale);

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

        String value = null;
        if (bundle.containsKey(name)) {
            value = bundle.getString(name);
        } else {
            if (Debug.warningOn()) {
                Debug.logWarning(name + " is missing in " + resource + " for locale " + locale, MODULE);
            }
            return name;
        }
        return value.trim();
    }

    /** Returns the value of the specified property name from the specified resource/properties file corresponding
     * to the given locale and replacing argument place holders with the given arguments using the MessageFormat class
     * @param resource The name of the resource - can be a file, class, or URL
     * @param name The name of the property in the properties file
     * @param arguments An array of Objects to insert into the message argument place holders
     * @param locale The locale that the given resource will correspond to
     * @return The value of the property in the properties file
     */
    public static String getMessage(String resource, String name, Object[] arguments, Locale locale) {
        String value = getMessage(resource, name, locale);

        if (UtilValidate.isEmpty(value)) {
            return "";
        }
        if (arguments != null && arguments.length > 0) {
            value = MessageFormat.format(value, arguments);
        }
        return value;
    }

    /** Returns the value of the specified property name from the specified resource/properties file corresponding
     * to the given locale and replacing argument place holders with the given arguments using the MessageFormat class
     * @param resource The name of the resource - can be a file, class, or URL
     * @param name The name of the property in the properties file
     * @param arguments A List of Objects to insert into the message argument place holders
     * @param locale The locale that the given resource will correspond to
     * @return The value of the property in the properties file
     */
    public static <E> String getMessage(String resource, String name, List<E> arguments, Locale locale) {
        String value = getMessage(resource, name, locale);

        if (UtilValidate.isEmpty(value)) {
            return "";
        }
        if (UtilValidate.isNotEmpty(arguments)) {
            value = MessageFormat.format(value, arguments.toArray());
        }
        return value;
    }

    public static String getMessageList(String resource, String name, Locale locale, Object... arguments) {
        return getMessage(resource, name, arguments, locale);
    }

    /** Returns the value of the specified property name from the specified resource/properties file corresponding
     * to the given locale and replacing argument place holders with the given arguments using the FlexibleStringExpander class
     * @param resource The name of the resource - can be a file, class, or URL
     * @param name The name of the property in the properties file
     * @param context A Map of Objects to insert into the message place holders using the ${} syntax of the FlexibleStringExpander
     * @param locale The locale that the given resource will correspond to
     * @return The value of the property in the properties file
     */
    public static String getMessage(String resource, String name, Map<String, ? extends Object> context, Locale locale) {
        String value = getMessage(resource, name, locale);

        if (UtilValidate.isEmpty(value)) {
            return "";
        }
        if (UtilValidate.isNotEmpty(context)) {
            value = FlexibleStringExpander.expandString(value, context, locale);
        }
        return value;
    }

    public static String getMessageMap(String resource, String name, Locale locale, Object... context) {
        return getMessage(resource, name, UtilMisc.toMap(context), locale);
    }

    private static Set<String> resourceNotFoundMessagesShown = new HashSet<>();
    /** Returns the specified resource/properties file as a ResourceBundle
     * @param resource The name of the resource - can be a file, class, or URL
     * @param locale The locale that the given resource will correspond to
     * @return The ResourceBundle
     */
    public static ResourceBundle getResourceBundle(String resource, Locale locale) {
        if (UtilValidate.isEmpty(resource)) {
            throw new IllegalArgumentException("resource cannot be null or empty");
        }
        if (locale == null) {
            throw new IllegalArgumentException("locale cannot be null");
        }
        ResourceBundle bundle = null;
        try {
            bundle = UtilResourceBundle.getBundle(resource, locale, (ClassLoader) null);
        } catch (MissingResourceException e) {
            String resourceCacheKey = createResourceName(resource, locale, false);
            if (!resourceNotFoundMessagesShown.contains(resourceCacheKey)) {
                resourceNotFoundMessagesShown.add(resourceCacheKey);
                Debug.logInfo("[UtilProperties.getPropertyValue] could not find resource: " + resource + " for locale " + locale, MODULE);
            }
            throw new IllegalArgumentException("Could not find resource bundle [" + resource + "] in the locale [" + locale + "]");
        }
        return bundle;
    }

    /** Returns the specified resource/properties file as a Map with the original
     *  ResourceBundle in the Map under the key _RESOURCE_BUNDLE_
     * @param resource The name of the resource - can be a file, class, or URL
     * @param locale The locale that the given resource will correspond to
     * @return Map containing all entries in The ResourceBundle
     */
    public static ResourceBundleMapWrapper getResourceBundleMap(String resource, Locale locale) {
        return new ResourceBundleMapWrapper(getResourceBundle(resource, locale));
    }

    /** Returns the specified resource/properties file as a Map with the original
     *  ResourceBundle in the Map under the key _RESOURCE_BUNDLE_
     * @param resource The name of the resource - can be a file, class, or URL
     * @param locale The locale that the given resource will correspond to
     * @param context The screen rendering context
     * @return Map containing all entries in The ResourceBundle
     */
    public static ResourceBundleMapWrapper getResourceBundleMap(String resource, Locale locale, Map<String, Object> context) {
        return new ResourceBundleMapWrapper(getResourceBundle(resource, locale), context);
    }

    /** Returns the specified resource/properties file.<p>Note that this method
     * will return a Properties instance for the specified locale <em>only</em> -
     * if you need <a href="http://www.w3.org/International/">I18n</a> properties, then use
     * <a href="#getResourceBundle(java.lang.String,%20java.util.Locale)">
     * getResourceBundle(String resource, Locale locale)</a>. This method is
     * intended to be used primarily by the UtilProperties class.</p>
     * @param resource The name of the resource - can be a file, class, or URL
     * @param locale The desired locale
     * @return The Properties instance, or null if no matching properties are found
     */
    public static Properties getProperties(String resource, Locale locale) {
        if (UtilValidate.isEmpty(resource)) {
            throw new IllegalArgumentException("resource cannot be null or empty");
        }
        if (locale == null) {
            throw new IllegalArgumentException("locale cannot be null");
        }
        Properties properties = null;
        URL url = resolvePropertiesUrl(resource, locale);
        if (url != null) {
            try {
                properties = new ExtendedProperties(url, locale);
            } catch (Exception e) {
                if (UtilValidate.isNotEmpty(e.getMessage())) {
                    Debug.logInfo(e.getMessage(), MODULE);
                } else {
                    Debug.logInfo("Exception thrown: " + e.getClass().getName(), MODULE);
                }
                properties = null;
            }
        }
        if (UtilValidate.isNotEmpty(properties)) {
            if (Debug.verboseOn()) {
                Debug.logVerbose("Loaded " + properties.size() + " properties for: " + resource + " (" + locale + ")", MODULE);
            }
        }
        return properties;
    }

    // ========= Classes and Methods for expanded Properties file support ========== //

    // Private lazy-initializer class
    private static class FallbackLocaleHolder {
        private static final Locale FALLBACK_LOCALE = getFallbackLocale();

        private static Locale getFallbackLocale() {
            Locale fallbackLocale = null;
            String locale = getPropertyValue("general", "locale.properties.fallback");
            if (UtilValidate.isNotEmpty(locale)) {
                fallbackLocale = UtilMisc.parseLocale(locale);
            }
            if (fallbackLocale == null) {
                fallbackLocale = Locale.ENGLISH;
            }
            return fallbackLocale;
        }
    }

    /** Returns the configured fallback locale. UtilProperties uses this locale
     * to resolve locale-specific XML properties.<p>The fallback locale can be
     * configured using the <code>locale.properties.fallback</code> property in
     * <code>general.properties</code>.
     * @return The configured fallback locale
     */
    public static Locale getFallbackLocale() {
        return FallbackLocaleHolder.FALLBACK_LOCALE;
    }

    /** Converts a Locale instance to a candidate Locale list. The list
     * is ordered most-specific to least-specific. Example:
     * <code>localeToCandidateList(Locale.US)</code> would return
     * a list containing <code>en_US</code> and <code>en</code>.
     * @return A list of candidate locales.
     */
    public static List<Locale> localeToCandidateList(Locale locale) {
        List<Locale> localeList = new LinkedList<>();
        localeList.add(locale);
        String localeString = locale.toString();
        int pos = localeString.lastIndexOf("_", localeString.length());
        while (pos != -1) {
            localeString = localeString.substring(0, pos);
            localeList.add(new Locale(localeString));
            pos = localeString.lastIndexOf("_", localeString.length());
        }
        return localeList;
    }

    // Private lazy-initializer class
    private static class CandidateLocalesHolder {
        private static Set<Locale> defaultCandidateLocales = getDefaultCandidateLocales();

        private static Set<Locale> getDefaultCandidateLocales() {
            Set<Locale> defaultCandidateLocales = new LinkedHashSet<>();
            defaultCandidateLocales.addAll(localeToCandidateList(Locale.getDefault()));
            defaultCandidateLocales.addAll(localeToCandidateList(getFallbackLocale()));
            defaultCandidateLocales.add(Locale.ROOT);
            return Collections.unmodifiableSet(defaultCandidateLocales);
        }
    }

    /** Returns the default candidate Locale list. The list is populated
     * with the JVM's default locale, the OFBiz fallback locale, and
     * the <code>LOCALE_ROOT</code> (empty) locale - in that order.
     * @return A list of default candidate locales.
     */
    public static Set<Locale> getDefaultCandidateLocales() {
        return CandidateLocalesHolder.defaultCandidateLocales;
    }

    /** Returns a list of candidate locales based on a supplied locale.
     * The returned list consists of the supplied locale and the
     * <a href="#getDefaultCandidateLocales()">default candidate locales</a>
     * - in that order.
     * @param locale The desired locale
     * @return A list of candidate locales
     */
    public static List<Locale> getCandidateLocales(Locale locale) {
        // Java 6 conformance
        if (Locale.ROOT.equals(locale)) {
            return UtilMisc.toList(locale);
        }
        Set<Locale> localeSet = new LinkedHashSet<>();
        localeSet.addAll(localeToCandidateList(locale));
        localeSet.addAll(getDefaultCandidateLocales());
        List<Locale> localeList = new ArrayList<>(localeSet);
        return localeList;
    }

    /** Create a localized resource name based on a resource name and
     * a locale.
     * @param resource The desired resource
     * @param locale The desired locale
     * @param removeExtension Remove file extension from resource String
     * @return Localized resource name
     */
    public static String createResourceName(String resource, Locale locale, boolean removeExtension) {
        String resourceName = resource;
        if (removeExtension) {
            if (resourceName.endsWith(".xml")) {
                resourceName = resourceName.replace(".xml", "");
            } else if (resourceName.endsWith(".properties")) {
                resourceName = resourceName.replace(".properties", "");
            }
        }
        if (locale != null) {
            if (UtilValidate.isNotEmpty(locale.toString())) {
                resourceName = resourceName + "_" + locale;
            }
        }
        return resourceName;
    }

    public static boolean isPropertiesResourceNotFound(String resource, Locale locale, boolean removeExtension) {
        return PROP_NOT_FOUND.contains(createResourceName(resource, locale, removeExtension));
    }

    /**
     * Resolve a properties file URL.
     * <p>This method uses the following strategy:</p>
     * <ul>
     *   <li>Locate the XML file specified in <code>resource (MyProps.xml)</code></li>
     *   <li>Locate the file that starts with the name specified in
     *     <code>resource</code> and ends with the locale's string and
     *     <code>.xml (MyProps_en.xml)</code>
     *   </li>
     *   <li>Locate the file that starts with the name specified in
     *     <code>resource</code> and ends with the locale's string and
     *     <code>.properties (MyProps_en.properties)</code>
     *   </li>
     *   <li>Locate the file that starts with the name specified in
     *     <code>resource and ends with the locale's string (MyProps_en)</code>
     *   </li>
     * </ul>
     * The <code>component://</code> protocol is supported in the
     * <code>resource</code> parameter.
     * @param resource The resource to resolve
     * @param locale The desired locale
     * @return A URL instance or null if not found.
     */
    public static URL resolvePropertiesUrl(String resource, Locale locale) {
        if (UtilValidate.isEmpty(resource)) {
            throw new IllegalArgumentException("resource cannot be null or empty");
        }
        String resourceName = createResourceName(resource, locale, false);
        if (PROP_NOT_FOUND.contains(resourceName)) {
            return null;
        }
        boolean containsProtocol = resource.contains(":");
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        URL url = null;
        try {
            // Check for complete URL first
            if (resource.endsWith(".xml") || resource.endsWith(".properties")) {
                if (containsProtocol) {
                    url = FlexibleLocation.resolveLocation(resource, loader);
                } else {
                    url = UtilURL.fromResource(resource, loader);
                }
                if (url != null) {
                    return url;
                }
            }
            // Check for *.properties file
            if (containsProtocol) {
                url = FlexibleLocation.resolveLocation(resourceName + ".properties", loader);
            } else {
                url = UtilURL.fromResource(resourceName + ".properties", loader);
            }
            if (url != null) {
                return url;
            }
            // Check for Java XML properties file
            if (containsProtocol) {
                url = FlexibleLocation.resolveLocation(resourceName + ".xml", loader);
            } else {
                url = UtilURL.fromResource(resourceName + ".xml", loader);
            }
            if (url != null) {
                return url;
            }
            // Check for Custom XML properties file
            if (containsProtocol) {
                url = FlexibleLocation.resolveLocation(resource + ".xml", loader);
            } else {
                url = UtilURL.fromResource(resource + ".xml", loader);
            }
            if (url != null) {
                return url;
            }
            if (containsProtocol) {
                url = FlexibleLocation.resolveLocation(resource, loader);
            } else {
                url = UtilURL.fromResource(resource, loader);
            }
            if (url != null) {
                return url;
            }
        } catch (Exception e) {
            Debug.logInfo("Properties resolver: invalid URL - " + e.getMessage(), MODULE);
        }
        if (PROP_NOT_FOUND.size() <= 300) {
            // Sanity check - list could get quite large
            PROP_NOT_FOUND.add(resourceName);
        }
        return null;
    }

    /**
     * Convert XML property file to Properties instance. This method will convert
     * both the Java XML properties file format and the OFBiz custom XML
     * properties file format.
     * <p>The format of the custom XML properties file is:</p>
     * <pre>
     * {@code
     * <resource>
     *     <property key="key">
     *     <value xml:lang="locale 1">Some value</value>
     *     <value xml:lang="locale 2">Some value</value>
     *     ...
     *     </property>
     *     ...
     * </resource>
     * }
     * </pre>
     * where <em>"locale 1", "locale 2"</em> are valid xml:lang values..
     * @param in XML file InputStream
     * @param locale The desired locale
     * @param properties Optional Properties object to populate
     * @return Properties instance or null if not found
     */
    public static Properties xmlToProperties(InputStream in, Locale locale, Properties properties)
            throws IOException, InvalidPropertiesFormatException {
        if (in == null) {
            throw new IllegalArgumentException("InputStream cannot be null");
        }
        Document doc = null;
        try {
            doc = UtilXml.readXmlDocument(in, true, "XML Properties file");
            in.close();
        } catch (Exception e) {
            Debug.logWarning(e, "XML file for locale " + locale + " could not be loaded.", MODULE);
            in.close();
            return null;
        }
        Element resourceElement = doc.getDocumentElement();
        List<? extends Element> propertyList = UtilXml.childElementList(resourceElement, "property");
        if (UtilValidate.isNotEmpty(propertyList)) {
            // Custom XML properties file format
            if (locale == null) {
                throw new IllegalArgumentException("locale cannot be null");
            }
            String localeString = locale.toString();
            String correctedLocaleString = localeString.replace('_', '-');
            for (Element property : propertyList) {
                // Support old way of specifying xml:lang value.
                // Old way: en_AU, new way: en-AU
                Element value = UtilXml.firstChildElement(property, "value", "xml:lang", correctedLocaleString);
                if (value == null) {
                    value = UtilXml.firstChildElement(property, "value", "xml:lang", localeString);
                }
                if (value != null) {
                    if (properties == null) {
                        properties = new Properties();
                    }
                    String valueString = UtilXml.elementValue(value);
                    if (valueString != null) {
                        properties.put(property.getAttribute("key"), valueString);
                    }
                }
            }
            return properties;
        }
        propertyList = UtilXml.childElementList(resourceElement, "entry");
        if (UtilValidate.isEmpty(propertyList)) {
            throw new InvalidPropertiesFormatException("XML properties file invalid or empty");
        }
        // Java XML properties file format
        for (Element property : propertyList) {
            String value = UtilXml.elementValue(property);
            if (value != null) {
                if (properties == null) {
                    properties = new Properties();
                }
                properties.put(property.getAttribute("key"), value);
            }
        }
        return properties;
    }

    /** Custom ResourceBundle class. This class extends ResourceBundle
     * to add custom bundle caching code and support for the OFBiz custom XML
     * properties file format.
     */
    public static class UtilResourceBundle extends ResourceBundle {
        private static final UtilCache<String, UtilResourceBundle> BUNDLE_CACHE = UtilCache.createUtilCache("properties.UtilPropertiesBundleCache");
        private Properties properties = null;
        private Locale locale = null;
        private int hashCode = hashCode();

        protected UtilResourceBundle() { }

        UtilResourceBundle(Properties properties, Locale locale, UtilResourceBundle parent) {
            this.properties = properties;
            this.locale = locale;
            setParent(parent);
            String hashString = properties.toString();
            if (parent != null) {
                hashString += parent.properties;
            }
            this.hashCode = hashString.hashCode();
        }

        public static ResourceBundle getBundle(String resource, Locale locale, ClassLoader loader) throws MissingResourceException {
            String resourceName = createResourceName(resource, locale, true);
            UtilResourceBundle bundle = BUNDLE_CACHE.get(resourceName);
            if (bundle == null) {
                double startTime = System.currentTimeMillis();
                List<Locale> candidateLocales = getCandidateLocales(locale);
                UtilResourceBundle parentBundle = null;
                int numProperties = 0;
                while (!candidateLocales.isEmpty()) {
                    Locale candidateLocale = candidateLocales.remove(candidateLocales.size() - 1);
                    // ResourceBundles are connected together as a singly-linked list
                    String lookupName = createResourceName(resource, candidateLocale, true);
                    UtilResourceBundle lookupBundle = BUNDLE_CACHE.get(lookupName);
                    if (lookupBundle == null) {
                        Properties newProps = getProperties(resource, candidateLocale);
                        if (UtilValidate.isNotEmpty(newProps)) {
                            // The last bundle we found becomes the parent of the new bundle
                            parentBundle = bundle;
                            bundle = new UtilResourceBundle(newProps, candidateLocale, parentBundle);
                            BUNDLE_CACHE.putIfAbsent(lookupName, bundle);
                            numProperties = newProps.size();
                        }
                    } else {
                        parentBundle = bundle;
                        bundle = lookupBundle;
                    }
                }
                if (bundle == null) {
                    throw new MissingResourceException("Resource " + resource + ", locale " + locale + " not found", null, null);
                } else if (!bundle.getLocale().equals(locale)) {
                    // Create a "dummy" bundle for the requested locale
                    bundle = new UtilResourceBundle(bundle.properties, locale, parentBundle);
                }
                double totalTime = System.currentTimeMillis() - startTime;
                if (Debug.infoOn()) {
                    Debug.logInfo("ResourceBundle " + resource + " (" + locale + ") created in " + totalTime / 1000.0 + "s with "
                            + numProperties + " properties", MODULE);
                }
                BUNDLE_CACHE.putIfAbsent(resourceName, bundle);
            }
            return bundle;
        }

        @Override
        public int hashCode() {
            return this.hashCode;
        }

        @Override
        public boolean equals(Object obj) {
            return obj == null ? false : obj.hashCode() == this.hashCode;
        }

        @Override
        public Locale getLocale() {
            return this.locale;
        }

        @Override
        protected Object handleGetObject(String key) {
            return properties.get(key);
        }

        @Override
        public Enumeration<String> getKeys() {
            return new Enumeration<String>() {
                private Iterator<String> i = UtilGenerics.cast(properties.keySet().iterator());
                @Override
                public boolean hasMoreElements() {
                    return (i.hasNext());
                }
                @Override
                public String nextElement() {
                    return i.next();
                }
            };
        }

    }

    /** Custom Properties class. Extended from Properties to add support
     * for the OFBiz custom XML file format.
     */
    public static class ExtendedProperties extends Properties {
        public ExtendedProperties() {
            super();
        }
        public ExtendedProperties(Properties defaults) {
            super(defaults);
        }
        ExtendedProperties(URL url, Locale locale) throws IOException, InvalidPropertiesFormatException {
            try (InputStream in = new BufferedInputStream(url.openStream())) {
                if (url.getFile().endsWith(".xml")) {
                    xmlToProperties(in, locale, this);
                } else {
                    load(in);
                }
            }
        }
        @Override
        public synchronized void loadFromXML(InputStream in) throws IOException, InvalidPropertiesFormatException {
            try {
                xmlToProperties(in, null, this);
            } finally {
                in.close();
            }
        }
    }
}
