/*
 * Copyright (c) 2015, 2024, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

/*
 * (C) Copyright Taligent, Inc. 1996, 1997 - All Rights Reserved
 * (C) Copyright IBM Corp. 1996 - 1999 - All Rights Reserved
 *
 * The original version of this source code and documentation
 * is copyrighted and owned by Taligent, Inc., a wholly-owned
 * subsidiary of IBM. These materials are provided under terms
 * of a License Agreement between Taligent and Sun. This technology
 * is protected by multiple US and International patents.
 *
 * This notice and attribution to Taligent may not be removed.
 * Taligent is a registered trademark of Taligent, Inc.
 *
 */

package sun.util.resources;

import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Objects;
import java.util.ResourceBundle;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.spi.ResourceBundleProvider;
import jdk.internal.access.JavaUtilResourceBundleAccess;
import jdk.internal.access.SharedSecrets;

/**
 */
public abstract class Bundles {

    /** initial size of the bundle cache */
    private static final int INITIAL_CACHE_SIZE = 32;

    /** constant indicating that no resource bundle exists */
    private static final ResourceBundle NONEXISTENT_BUNDLE = new ResourceBundle() {
            @Override
            public Enumeration<String> getKeys() { return null; }
            @Override
            protected Object handleGetObject(String key) { return null; }
            @Override
            public String toString() { return "NONEXISTENT_BUNDLE"; }
        };

    private static final JavaUtilResourceBundleAccess bundleAccess
                            = SharedSecrets.getJavaUtilResourceBundleAccess();

    /**
     * The cache is a map from cache keys (with bundle base name, locale, and
     * class loader) to either a resource bundle or NONEXISTENT_BUNDLE wrapped by a
     * BundleReference.
     *
     * The cache is a ConcurrentMap, allowing the cache to be searched
     * concurrently by multiple threads.  This will also allow the cache keys
     * to be reclaimed along with the ClassLoaders they reference.
     *
     * This variable would be better named "cache", but we keep the old
     * name for compatibility with some workarounds for bug 4212439.
     */
    private static final ConcurrentMap<CacheKey, BundleReference> cacheList
                            = new ConcurrentHashMap<>(INITIAL_CACHE_SIZE);

    /**
     * Queue for reference objects referring to class loaders or bundles.
     */
    private static final ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();

    private Bundles() {
    }

    public static ResourceBundle of(String baseName, Locale locale, Strategy strategy) {
        return loadBundleOf(baseName, locale, strategy);
    }

    private static ResourceBundle loadBundleOf(String baseName,
                                               Locale targetLocale,
                                               Strategy strategy) {
        Objects.requireNonNull(baseName);
        Objects.requireNonNull(targetLocale);
        Objects.requireNonNull(strategy);

        CacheKey cacheKey = new CacheKey(baseName, targetLocale);

        ResourceBundle bundle = null;

        // Quick lookup of the cache.
        BundleReference bundleRef = cacheList.get(cacheKey);
        if (bundleRef != null) {
            bundle = bundleRef.get();
        }

        // If this bundle and all of its parents are valid,
        // then return this bundle.
        if (isValidBundle(bundle)) {
            return bundle;
        }

        // Get the providers for loading the "leaf" bundle (i.e., bundle for
        // targetLocale). If no providers are required for the bundle,
        // none of its parents will require providers.
        Class<? extends ResourceBundleProvider> type
                = strategy.getResourceBundleProviderType(baseName, targetLocale);
        if (type != null) {
            @SuppressWarnings("unchecked")
            ServiceLoader<ResourceBundleProvider> providers
                = (ServiceLoader<ResourceBundleProvider>) ServiceLoader.loadInstalled(type);
            cacheKey.setProviders(providers);
        }

        List<Locale> candidateLocales = strategy.getCandidateLocales(baseName, targetLocale);
        bundle = findBundleOf(cacheKey, strategy, baseName, candidateLocales, 0);
        if (bundle == null) {
            throwMissingResourceException(baseName, targetLocale, cacheKey.getCause());
        }
        return bundle;
    }

    private static ResourceBundle findBundleOf(CacheKey cacheKey,
                                               Strategy strategy,
                                               String baseName,
                                               List<Locale> candidateLocales,
                                               int index) {
        ResourceBundle parent = null;
        Locale targetLocale = candidateLocales.get(index);
        if (index != candidateLocales.size() - 1) {
            parent = findBundleOf(cacheKey, strategy, baseName, candidateLocales, index + 1);
        }

        // Before we do the real loading work, see whether we need to
        // do some housekeeping: If resource bundles have been nulled out,
        // remove all related information from the cache.
        cleanupCache();

        // find an individual ResourceBundle in the cache
        cacheKey.setLocale(targetLocale);
        ResourceBundle bundle = findBundleInCache(cacheKey);
        if (bundle != null) {
            if (bundle == NONEXISTENT_BUNDLE) {
                return parent;
            }
            if (bundleAccess.getParent(bundle) == parent) {
                return bundle;
            }
            // Remove bundle from the cache.
            BundleReference bundleRef = cacheList.get(cacheKey);
            if (bundleRef != null && bundleRef.get() == bundle) {
                cacheList.remove(cacheKey, bundleRef);
            }
        }

        // Determine if providers should be used for loading the bundle.
        // An assumption here is that if the leaf bundle of a look-up path is
        // in java.base, all bundles of the path are in java.base.
        // (e.g., en_US of path en_US -> en -> root is in java.base and the rest
        // are in java.base as well)
        // This assumption isn't valid for general bundle loading.
        ServiceLoader<ResourceBundleProvider> providers = cacheKey.getProviders();
        if (providers != null) {
            if (strategy.getResourceBundleProviderType(baseName, targetLocale) == null) {
                providers = null;
            }
        }

        CacheKey constKey = (CacheKey) cacheKey.clone();
        try {
            if (providers != null) {
                bundle = loadBundleFromProviders(baseName, targetLocale, providers, cacheKey);
            } else {
                try {
                    String bundleName = strategy.toBundleName(baseName, targetLocale);
                    Class<?> c = Class.forName(Bundles.class.getModule(), bundleName);
                    if (c != null && ResourceBundle.class.isAssignableFrom(c)) {
                        @SuppressWarnings("unchecked")
                        Class<ResourceBundle> bundleClass = (Class<ResourceBundle>) c;
                        bundle = bundleAccess.newResourceBundle(bundleClass);
                    }
                    if (bundle == null) {
                        var otherBundleName = toOtherBundleName(baseName, bundleName, targetLocale);
                        if (!bundleName.equals(otherBundleName)) {
                            c = Class.forName(Bundles.class.getModule(), otherBundleName);
                            if (c != null && ResourceBundle.class.isAssignableFrom(c)) {
                                @SuppressWarnings("unchecked")
                                Class<ResourceBundle> bundleClass = (Class<ResourceBundle>) c;
                                bundle = bundleAccess.newResourceBundle(bundleClass);
                            }
                        }
                    }
                } catch (Exception e) {
                    cacheKey.setCause(e);
                }
            }
        } finally {
            if (constKey.getCause() instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
        }

        if (bundle == null) {
            // Put NONEXISTENT_BUNDLE in the cache as a mark that there's no bundle
            // instance for the locale.
            putBundleInCache(cacheKey, NONEXISTENT_BUNDLE);
            return parent;
        }

        if (parent != null && bundleAccess.getParent(bundle) == null) {
            bundleAccess.setParent(bundle, parent);
        }
        bundleAccess.setLocale(bundle, targetLocale);
        bundleAccess.setName(bundle, baseName);
        bundle = putBundleInCache(cacheKey, bundle);
        return bundle;
    }

    private static void cleanupCache() {
        Object ref;
        while ((ref = referenceQueue.poll()) != null) {
            cacheList.remove(((CacheKeyReference)ref).getCacheKey());
        }
    }

    /**
     * Loads ResourceBundle from service providers.
     */
    private static ResourceBundle loadBundleFromProviders(String baseName,
                                                          Locale locale,
                                                          ServiceLoader<ResourceBundleProvider> providers,
                                                          CacheKey cacheKey)
    {
        for (Iterator<ResourceBundleProvider> itr = providers.iterator(); itr.hasNext(); ) {
            try {
                ResourceBundleProvider provider = itr.next();
                ResourceBundle bundle = provider.getBundle(baseName, locale);
                if (bundle != null) {
                    return bundle;
                }
            } catch (ServiceConfigurationError e) {
                if (cacheKey != null) {
                    cacheKey.setCause(e);
                }
            }
        }
        return null;
    }

    private static boolean isValidBundle(ResourceBundle bundle) {
        return bundle != null && bundle != NONEXISTENT_BUNDLE;
    }

    /**
     * Throw a MissingResourceException with proper message
     */
    private static void throwMissingResourceException(String baseName,
                                                      Locale locale,
                                                      Throwable cause) {
        // If the cause is a MissingResourceException, avoid creating
        // a long chain. (6355009)
        if (cause instanceof MissingResourceException) {
            cause = null;
        }
        MissingResourceException e;
        e = new MissingResourceException("Can't find bundle for base name "
                                         + baseName + ", locale " + locale,
                                         baseName + "_" + locale, // className
                                         "");
        e.initCause(cause);
        throw e;
    }

    /**
     * Finds a bundle in the cache.
     *
     * @param cacheKey the key to look up the cache
     * @return the ResourceBundle found in the cache or null
     */
    private static ResourceBundle findBundleInCache(CacheKey cacheKey) {
        BundleReference bundleRef = cacheList.get(cacheKey);
        if (bundleRef == null) {
            return null;
        }
        return bundleRef.get();
    }

    /**
     * Put a new bundle in the cache.
     *
     * @param cacheKey the key for the resource bundle
     * @param bundle the resource bundle to be put in the cache
     * @return the ResourceBundle for the cacheKey; if someone has put
     * the bundle before this call, the one found in the cache is
     * returned.
     */
    private static ResourceBundle putBundleInCache(CacheKey cacheKey,
                                                   ResourceBundle bundle) {
        CacheKey key = (CacheKey) cacheKey.clone();
        BundleReference bundleRef = new BundleReference(bundle, referenceQueue, key);

        // Put the bundle in the cache if it's not been in the cache.
        BundleReference result = cacheList.putIfAbsent(key, bundleRef);

        // If someone else has put the same bundle in the cache before
        // us, we should use the one in the cache.
        if (result != null) {
            ResourceBundle rb = result.get();
            if (rb != null) {
                // Clear the back link to the cache key
                bundle = rb;
                // Clear the reference in the BundleReference so that
                // it won't be enqueued.
                bundleRef.clear();
            } else {
                // Replace the invalid (garbage collected)
                // instance with the valid one.
                cacheList.put(key, bundleRef);
            }
        }
        return bundle;
    }

    /**
     * Generates the other bundle name for languages that have changed,
     * i.e. "he", "id", and "yi"
     *
     * @param baseName ResourceBundle base name
     * @param bundleName ResourceBundle bundle name
     * @param locale locale
     * @return the other bundle name, or the same name for non-legacy ISO languages
     */
    public static String toOtherBundleName(String baseName, String bundleName, Locale locale) {
        var simpleName= baseName.substring(baseName.lastIndexOf('.') + 1);
        var suffix = bundleName.substring(bundleName.lastIndexOf(simpleName) + simpleName.length());
        var otherSuffix = switch(locale.getLanguage()) {
            case "he" -> suffix.replaceFirst("^_he(_.*)?$", "_iw$1");
            case "id" -> suffix.replaceFirst("^_id(_.*)?$", "_in$1");
            case "yi" -> suffix.replaceFirst("^_yi(_.*)?$", "_ji$1");
            case "iw" -> suffix.replaceFirst("^_iw(_.*)?$", "_he$1");
            case "in" -> suffix.replaceFirst("^_in(_.*)?$", "_id$1");
            case "ji" -> suffix.replaceFirst("^_ji(_.*)?$", "_yi$1");
            default -> suffix;
        };

        if (suffix.equals(otherSuffix)) {
            return bundleName;
        } else {
            return bundleName.substring(0, bundleName.lastIndexOf(suffix)) + otherSuffix;
        }
    }

    /**
     * The Strategy interface defines methods that are called by Bundles.of during
     * the resource bundle loading process.
     */
    public interface Strategy {
        /**
         * Returns a list of locales to be looked up for bundle loading.
         */
        List<Locale> getCandidateLocales(String baseName, Locale locale);

        /**
         * Returns the bundle name for the given baseName and locale.
         */
        String toBundleName(String baseName, Locale locale);

        /**
         * Returns the service provider type for the given baseName
         * and locale, or null if no service providers should be used.
         */
        Class<? extends ResourceBundleProvider> getResourceBundleProviderType(String baseName,
                                                                                     Locale locale);
    }

    /**
     * The common interface to get a CacheKey in LoaderReference and
     * BundleReference.
     */
    private static interface CacheKeyReference {
        CacheKey getCacheKey();
    }

    /**
     * References to bundles are soft references so that they can be garbage
     * collected when they have no hard references.
     */
    private static class BundleReference extends SoftReference<ResourceBundle>
                                         implements CacheKeyReference {
        private final CacheKey cacheKey;

        BundleReference(ResourceBundle referent, ReferenceQueue<Object> q, CacheKey key) {
            super(referent, q);
            cacheKey = key;
        }

        @Override
        public CacheKey getCacheKey() {
            return cacheKey;
        }
    }

    /**
     * Key used for cached resource bundles.  The key checks the base
     * name, the locale, and the class loader to determine if the
     * resource is a match to the requested one. The loader may be
     * null, but the base name and the locale must have a non-null
     * value.
     */
    private static class CacheKey implements Cloneable {
        // These two are the actual keys for lookup in Map.
        private String name;
        private Locale locale;

        // Placeholder for an error report by a Throwable
        private Throwable cause;

        // Hash code value cache to avoid recalculating the hash code
        // of this instance.
        private int hashCodeCache;

        // The service loader to load bundles or null if no service loader
        // is required.
        private ServiceLoader<ResourceBundleProvider> providers;

        CacheKey(String baseName, Locale locale) {
            this.name = baseName;
            this.locale = locale;
            calculateHashCode();
        }

        String getName() {
            return name;
        }

        CacheKey setName(String baseName) {
            if (!this.name.equals(baseName)) {
                this.name = baseName;
                calculateHashCode();
            }
            return this;
        }

        Locale getLocale() {
            return locale;
        }

        CacheKey setLocale(Locale locale) {
            if (!this.locale.equals(locale)) {
                this.locale = locale;
                calculateHashCode();
            }
            return this;
        }

        ServiceLoader<ResourceBundleProvider> getProviders() {
            return providers;
        }

        void setProviders(ServiceLoader<ResourceBundleProvider> providers) {
            this.providers = providers;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) {
                return true;
            }
            if (other instanceof CacheKey otherEntry) {
                //quick check to see if they are not equal
                if (hashCodeCache != otherEntry.hashCodeCache) {
                    return false;
                }
                return locale.equals(otherEntry.locale)
                        && name.equals(otherEntry.name);
            }
            return false;
        }

        @Override
        public int hashCode() {
            return hashCodeCache;
        }

        private void calculateHashCode() {
            hashCodeCache = name.hashCode() << 3;
            hashCodeCache ^= locale.hashCode();
        }

        @Override
        public Object clone() {
            try {
                CacheKey clone = (CacheKey) super.clone();
                // Clear the reference to a Throwable
                clone.cause = null;
                // Clear the reference to a ServiceLoader
                clone.providers = null;
                return clone;
            } catch (CloneNotSupportedException e) {
                //this should never happen
                throw new InternalError(e);
            }
        }

        private void setCause(Throwable cause) {
            if (this.cause == null) {
                this.cause = cause;
            } else {
                // Override the cause if the previous one is
                // ClassNotFoundException.
                if (this.cause instanceof ClassNotFoundException) {
                    this.cause = cause;
                }
            }
        }

        private Throwable getCause() {
            return cause;
        }

        @Override
        public String toString() {
            String l = locale.toString();
            if (l.isEmpty()) {
                if (!locale.getVariant().isEmpty()) {
                    l = "__" + locale.getVariant();
                } else {
                    l = "\"\"";
                }
            }
            return "CacheKey[" + name + ", lc=" + l + ")]";
        }
    }
}
