/*
 * Copyright (c) 1997-2020 Oracle and/or its affiliates. All rights reserved.
 * Copyright 2004 The Apache Software Foundation
 * Copyright (c) 2021-2021 Contributors to the Eclipse Foundation
 *
 * Licensed 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 jakarta.servlet.jsp.jstl.fmt;

import java.text.MessageFormat;
import java.util.Enumeration;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;

import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.jsp.PageContext;
import jakarta.servlet.jsp.jstl.core.Config;

/**
 * Class which exposes the locale-determination logic for resource bundles through convenience methods.
 *
 * <p>
 * This class may be useful to any tag handler implementation that needs to produce localized messages. For example,
 * this might be useful for exception messages that are intended directly for user consumption on an error page.
 *
 * @author Jan Luehe
 */

public class LocaleSupport {

    private static final String UNDEFINED_KEY = "???";
    private static final char HYPHEN = '-';
    private static final char UNDERSCORE = '_';
    private static final String REQUEST_CHAR_SET = "jakarta.servlet.jsp.jstl.fmt.request.charset";
    private static final Locale EMPTY_LOCALE = new Locale("", "");

    /**
     * Retrieves the localized message corresponding to the given key.
     *
     * <p>
     * The given key is looked up in the resource bundle of the default I18N localization context, which is retrieved from
     * the <code>jakarta.servlet.jsp.jstl.fmt.localizationContext</code> configuration setting.
     *
     * <p>
     * If the configuration setting is empty, or the default I18N localization context does not contain any resource bundle,
     * or the given key is undefined in its resource bundle, the string "???&lt;key&gt;???" is returned, where "&lt;key&gt;"
     * is replaced with the given key.
     *
     * @param pageContext the page in which to get the localized message corresponding to the given key
     * @param key the message key
     *
     * @return the localized message corresponding to the given key
     */
    public static String getLocalizedMessage(PageContext pageContext, String key) {
        return getLocalizedMessage(pageContext, key, null, null);
    }

    /**
     * Retrieves the localized message corresponding to the given key.
     *
     * <p>
     * The given key is looked up in the resource bundle with the given base name.
     *
     * <p>
     * If no resource bundle with the given base name exists, or the given key is undefined in the resource bundle, the
     * string "???&lt;key&gt;???" is returned, where "&lt;key&gt;" is replaced with the given key.
     *
     * @param pageContext the page in which to get the localized message corresponding to the given key
     * @param key the message key
     * @param basename the resource bundle base name
     *
     * @return the localized message corresponding to the given key
     */
    public static String getLocalizedMessage(PageContext pageContext, String key, String basename) {
        return getLocalizedMessage(pageContext, key, null, basename);
    }

    /**
     * Retrieves the localized message corresponding to the given key, and performs parametric replacement using the
     * arguments specified via <code>args</code>.
     *
     * <p>
     * See the specification of the &lt;fmt:message&gt; action for a description of how parametric replacement is
     * implemented.
     *
     * <p>
     * The localized message is retrieved as in
     * {@link #getLocalizedMessage(jakarta.servlet.jsp.PageContext,java.lang.String) getLocalizedMessage(pageContext, key)}.
     *
     * @param pageContext the page in which to get the localized message corresponding to the given key
     * @param key the message key
     * @param args the arguments for parametric replacement
     *
     * @return the localized message corresponding to the given key
     */
    public static String getLocalizedMessage(PageContext pageContext, String key, Object[] args) {
        return getLocalizedMessage(pageContext, key, args, null);
    }

    /**
     * Retrieves the localized message corresponding to the given key, and performs parametric replacement using the
     * arguments specified via <code>args</code>.
     *
     * <p>
     * See the specification of the &lt;fmt:message&gt; action for a description of how parametric replacement is
     * implemented.
     *
     * <p>
     * The localized message is retrieved as in
     * {@link #getLocalizedMessage(jakarta.servlet.jsp.PageContext,java.lang.String, java.lang.String)
     * getLocalizedMessage(pageContext, key, basename)}.
     *
     * @param pageContext the page in which to get the localized message corresponding to the given key
     * @param key the message key
     * @param args the arguments for parametric replacement
     * @param basename the resource bundle base name
     *
     * @return the localized message corresponding to the given key
     */
    public static String getLocalizedMessage(PageContext pageContext, String key, Object[] args, String basename) {
        LocalizationContext locCtxt = null;
        String message = UNDEFINED_KEY + key + UNDEFINED_KEY;

        if (basename != null) {
            locCtxt = getLocalizationContext(pageContext, basename);
        } else {
            locCtxt = getLocalizationContext(pageContext);
        }

        if (locCtxt != null) {
            ResourceBundle bundle = locCtxt.getResourceBundle();
            if (bundle != null) {
                try {
                    message = bundle.getString(key);
                    if (args != null) {
                        MessageFormat formatter = new MessageFormat("");
                        if (locCtxt.getLocale() != null) {
                            formatter.setLocale(locCtxt.getLocale());
                        }
                        formatter.applyPattern(message);
                        message = formatter.format(args);
                    }
                } catch (MissingResourceException mre) {
                }
            }
        }

        return message;
    }

    /**
     * Gets the default I18N localization context.
     *
     * @param pc Page in which to look up the default I18N localization context
     */
    private static LocalizationContext getLocalizationContext(PageContext pc) {
        LocalizationContext locCtxt = null;

        Object obj = Config.find(pc, Config.FMT_LOCALIZATION_CONTEXT);
        if (obj == null) {
            return null;
        }

        if (obj instanceof LocalizationContext) {
            locCtxt = (LocalizationContext) obj;
        } else {
            // localization context is a bundle basename
            locCtxt = getLocalizationContext(pc, (String) obj);
        }

        return locCtxt;
    }

    /**
     * Gets the resource bundle with the given base name, whose locale is determined as follows:
     *
     * Check if a match exists between the ordered set of preferred locales and the available locales, for the given base
     * name. The set of preferred locales consists of a single locale (if the <code>jakarta.servlet.jsp.jstl.fmt.locale</code>
     * configuration setting is present) or is equal to the client's preferred locales determined from the client's browser
     * settings.
     *
     * <p>
     * If no match was found in the previous step, check if a match exists between the fallback locale (given by the
     * <code>jakarta.servlet.jsp.jstl.fmt.fallbackLocale</code> configuration setting) and the available locales, for the given
     * base name.
     *
     * @param pageContext Page in which the resource bundle with the given base name is requested
     * @param basename Resource bundle base name
     *
     * @return Localization context containing the resource bundle with the given base name and the locale that led to the
     * resource bundle match, or the empty localization context if no resource bundle match was found
     */
    private static LocalizationContext getLocalizationContext(PageContext pc, String basename) {
        LocalizationContext locCtxt = null;
        ResourceBundle bundle = null;

        if (basename == null || basename.equals("")) {
            return new LocalizationContext();
        }

        // Try preferred locales
        Locale pref = getLocale(pc, Config.FMT_LOCALE);
        if (pref != null) {
            // Preferred locale is application-based
            bundle = findMatch(basename, pref);
            if (bundle != null) {
                locCtxt = new LocalizationContext(bundle, pref);
            }
        } else {
            // Preferred locales are browser-based
            locCtxt = findMatch(pc, basename);
        }

        if (locCtxt == null) {
            // No match found with preferred locales, try using fallback locale
            pref = getLocale(pc, Config.FMT_FALLBACK_LOCALE);
            if (pref != null) {
                bundle = findMatch(basename, pref);
                if (bundle != null) {
                    locCtxt = new LocalizationContext(bundle, pref);
                }
            }
        }

        if (locCtxt == null) {
            // try using the root resource bundle with the given basename
            try {
                bundle = ResourceBundle.getBundle(basename, EMPTY_LOCALE, Thread.currentThread().getContextClassLoader());
                if (bundle != null) {
                    locCtxt = new LocalizationContext(bundle, null);
                }
            } catch (MissingResourceException mre) {
                // do nothing
            }
        }

        if (locCtxt != null) {
            // set response locale
            if (locCtxt.getLocale() != null) {
                setResponseLocale(pc, locCtxt.getLocale());
            }
        } else {
            // create empty localization context
            locCtxt = new LocalizationContext();
        }

        return locCtxt;
    }

    /*
     * Determines the client's preferred locales from the request, and compares each of the locales (in order of preference)
     * against the available locales in order to determine the best matching locale.
     *
     * @param pageContext the page in which the resource bundle with the given base name is requested
     *
     * @param basename the resource bundle's base name
     *
     * @return the localization context containing the resource bundle with the given base name and best matching locale, or
     * <code>null</code> if no resource bundle match was found
     */
    private static LocalizationContext findMatch(PageContext pageContext, String basename) {
        LocalizationContext locCtxt = null;

        // Determine locale from client's browser settings.

        for (Enumeration enum_ = getRequestLocales((HttpServletRequest) pageContext.getRequest()); enum_.hasMoreElements();) {
            Locale pref = (Locale) enum_.nextElement();
            ResourceBundle match = findMatch(basename, pref);
            if (match != null) {
                locCtxt = new LocalizationContext(match, pref);
                break;
            }
        }

        return locCtxt;
    }

    /*
     * Gets the resource bundle with the given base name and preferred locale.
     *
     * This method calls java.util.ResourceBundle.getBundle(), but ignores its return value unless its locale represents an
     * exact or language match with the given preferred locale.
     *
     * @param basename the resource bundle base name
     *
     * @param pref the preferred locale
     *
     * @return the requested resource bundle, or <code>null</code> if no resource bundle with the given base name exists or if
     * there is no exact- or language-match between the preferred locale and the locale of the bundle returned by
     * java.util.ResourceBundle.getBundle().
     */
    private static ResourceBundle findMatch(String basename, Locale pref) {
        ResourceBundle match = null;

        try {
            ResourceBundle bundle = ResourceBundle.getBundle(basename, pref, Thread.currentThread().getContextClassLoader());
            Locale avail = bundle.getLocale();
            if (pref.equals(avail) || (pref.getLanguage().equals(avail.getLanguage()) && ("".equals(avail.getCountry()) || pref.getCountry().equals(avail.getCountry())))) {
                // Exact match
                match = bundle;
            }
        } catch (MissingResourceException mre) {
        }

        return match;
    }

    /*
     * Returns the locale specified by the named scoped attribute or context configuration parameter.
     *
     * <p> The named scoped attribute is searched in the page, request, session (if valid), and application scope(s) (in
     * this order). If no such attribute exists in any of the scopes, the locale is taken from the named context
     * configuration parameter.
     *
     * @param pageContext the page in which to search for the named scoped attribute or context configuration parameter
     *
     * @param name the name of the scoped attribute or context configuration parameter
     *
     * @return the locale specified by the named scoped attribute or context configuration parameter, or <tt>null</tt> if no
     * scoped attribute or configuration parameter with the given name exists
     */
    private static Locale getLocale(PageContext pageContext, String name) {
        Locale loc = null;

        Object obj = Config.find(pageContext, name);
        if (obj != null) {
            if (obj instanceof Locale) {
                loc = (Locale) obj;
            } else {
                loc = parseLocale((String) obj);
            }
        }

        return loc;
    }

    /*
     * Stores the given locale in the response object of the given page context, and stores the locale's associated charset
     * in the jakarta.servlet.jsp.jstl.fmt.request.charset session attribute, which may be used by the <requestEncoding>
     * action in a page invoked by a form included in the response to set the request charset to the same as the response
     * charset (this makes it possible for the container to decode the form parameter values properly, since browsers
     * typically encode form field values using the response's charset).
     *
     * @param pageContext the page context whose response object is assigned the given locale
     *
     * @param locale the response locale
     */
    private static void setResponseLocale(PageContext pc, Locale locale) {
        // set response locale
        ServletResponse response = pc.getResponse();
        response.setLocale(locale);

        // get response character encoding and store it in session attribute
        if (pc.getSession() != null) {
            try {
                pc.setAttribute(REQUEST_CHAR_SET, response.getCharacterEncoding(), PageContext.SESSION_SCOPE);
            } catch (IllegalStateException ex) {
            } // invalidated session ignored
        }
    }

    /**
     * See parseLocale(String, String) for details.
     */
    private static Locale parseLocale(String locale) {
        return parseLocale(locale, null);
    }

    /**
     * Parses the given locale string into its language and (optionally) country components, and returns the corresponding
     * <code>java.util.Locale</code> object.
     *
     * If the given locale string is null or empty, the runtime's default locale is returned.
     *
     * @param locale the locale string to parse
     * @param variant the variant
     *
     * @return <code>java.util.Locale</code> object corresponding to the given locale string, or the runtime's default locale if
     * the locale string is null or empty
     *
     * @throws IllegalArgumentException if the given locale does not have a language component or has an empty country
     * component
     */
    private static Locale parseLocale(String locale, String variant) {

        Locale ret = null;
        String language = locale;
        String country = null;
        int index = -1;

        if ((index = locale.indexOf(HYPHEN)) > -1 || (index = locale.indexOf(UNDERSCORE)) > -1) {
            language = locale.substring(0, index);
            country = locale.substring(index + 1);
        }

        if (language == null || language.length() == 0) {
            throw new IllegalArgumentException("Missing language component in 'value' attribute in setLocale");
        }

        if (country == null) {
            if (variant != null) {
                ret = new Locale(language, "", variant);
            } else {
                ret = new Locale(language, "");
            }
        } else if (country.length() > 0) {
            if (variant != null) {
                ret = new Locale(language, country, variant);
            } else {
                ret = new Locale(language, country);
            }
        } else {
            throw new IllegalArgumentException("Empty country component in 'value' attribute in setLocale");
        }

        return ret;
    }

    /**
     * HttpServletRequest.getLocales() returns the server's default locale if the request did not specify a preferred
     * language. We do not want this behavior, because it prevents us from using the fallback locale. We therefore need to
     * return an empty Enumeration if no preferred locale has been specified. This way, the logic for the fallback locale
     * will be able to kick in.
     */
    private static Enumeration getRequestLocales(HttpServletRequest request) {
        Enumeration values = request.getHeaders("accept-language");
        if (values.hasMoreElements()) {
            // At least one "accept-language". Simply return
            // the enumeration returned by request.getLocales().
            // System.out.println("At least one accept-language");
            return request.getLocales();
        } else {
            // No header for "accept-language". Simply return
            // the empty enumeration.
            // System.out.println("No accept-language");
            return values;
        }
    }

}
