// ***************************************************************************************************************************
// * 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.juneau.utils;

import static org.apache.juneau.internal.StringUtils.*;
import static org.apache.juneau.internal.ThrowableUtils.*;

import java.text.*;
import java.util.*;
import java.util.concurrent.*;

import org.apache.juneau.*;

/**
 * Wraps a {@link ResourceBundle} to provide some useful additional functionality.
 * 
 * <ul class='spaced-list'>
 * 	<li>
 * 		Instead of throwing {@link MissingResourceException}, the {@link #getString(String)} method
 * 		will return <js>"{!!key}"</js> if the bundle was not found, and <js>"{!key}"</js> if bundle
 * 		was found but the key is not in the bundle.
 * 	<li>
 * 		A client locale can be set as a {@link ThreadLocal} object using the static {@link #setClientLocale(Locale)}
 * 		so that client localized messages can be retrieved using the {@link #getClientString(String, Object...)}
 * 		method on all instances of this class.
 * 	<li>
 * 		Resource bundles on parent classes can be added to the search path for this class by using the
 * 		{@link #addSearchPath(Class, String)} method.
 * 		This allows messages to be retrieved from the resource bundles of parent classes.
 * 	<li>
 * 		Locale-specific bundles can be retrieved by using the {@link #getBundle(Locale)} method.
 * 	<li>
 * 		The {@link #getString(Locale, String, Object...)} method can be used to retrieve locale-specific messages.
 * 	<li>
 * 		Messages in the resource bundle can optionally be prefixed with the simple class name.
 * 		For example, if the class is <code>MyClass</code> and the properties file contains <js>"MyClass.myMessage"</js>,
 * 		the message can be retrieved using <code>getString(<js>"myMessage"</js>)</code>.
 * </ul>
 * 
 * <h5 class='section'>Notes:</h5>
 * <ul class='spaced-list'>
 * 	<li>
 * 		This class is thread-safe.
 * </ul>
 */
public class MessageBundle extends ResourceBundle {

	private static final ThreadLocal<Locale> clientLocale = new ThreadLocal<>();

	private final ResourceBundle rb;
	private final String bundlePath, className;
	private final Class<?> forClass;
	private final long creationThreadId;

	// A map that contains all keys [shortKeyName->keyName] and [keyName->keyName], where shortKeyName
	// refers to keys prefixed and stripped of the class name (e.g. "foobar"->"MyClass.foobar")
	private final Map<String,String> keyMap = new ConcurrentHashMap<>();

	// Contains all keys present in all bundles in searchBundles.
	private final ConcurrentSkipListSet<String> allKeys = new ConcurrentSkipListSet<>();

	// Bundles to search through to find properties.
	// Typically this will be a list of resource bundles for each class up the class hierarchy chain.
	private final CopyOnWriteArrayList<MessageBundle> searchBundles = new CopyOnWriteArrayList<>();

	// Cache of message bundles per locale.
	private final ConcurrentHashMap<Locale,MessageBundle> localizedBundles = new ConcurrentHashMap<>();

	/**
	 * Sets the locale for this thread so that calls to {@link #getClientString(String, Object...)} return messages in
	 * that locale.
	 * 
	 * @param locale The new client locale.
	 */
	public static void setClientLocale(Locale locale) {
		MessageBundle.clientLocale.set(locale);
	}

	/**
	 * Constructor.
	 * 
	 * <p>
	 * When this method is used, the bundle path is determined by searching for the resource bundle
	 * in the following locations:
	 * <ul>
	 * 	<li><code>[package].ForClass.properties</code>
	 * 	<li><code>[package].nls.ForClass.properties</code>
	 * 	<li><code>[package].i18n.ForClass.properties</code>
	 * </ul>
	 * 
	 * @param forClass The class 
	 * @return A new message bundle belonging to the class.
	 */
	public static final MessageBundle create(Class<?> forClass) {
		return create(forClass, findBundlePath(forClass));
	}
	
	/**
	 * Constructor.
	 * 
	 * <p>
	 * A shortcut for calling <code>new MessageBundle(forClass, bundlePath)</code>.
	 * 
	 * @param forClass The class 
	 * @param bundlePath The location of the resource bundle.
	 * @return A new message bundle belonging to the class.
	 */
	public static final MessageBundle create(Class<?> forClass, String bundlePath) {
		return new MessageBundle(forClass, bundlePath);
	}
	
	private static final String findBundlePath(Class<?> forClass) {
		String path = forClass.getName();
		if (tryBundlePath(forClass, path))
			return path;
		path = forClass.getPackage().getName() + ".nls." + forClass.getSimpleName(); 
		if (tryBundlePath(forClass, path))
			return path;
		path = forClass.getPackage().getName() + ".i18n." + forClass.getSimpleName();
		if (tryBundlePath(forClass, path))
			return path;
		return null;
	}
	
	private static final boolean tryBundlePath(Class<?> c, String path) {
		try {
			path = c.getName();
			ResourceBundle.getBundle(path, Locale.getDefault(), c.getClassLoader());
			return true;
		} catch (MissingResourceException e) {
			return false;
		}
	}

	/**
	 * Constructor.
	 * 
	 * @param forClass The class using this resource bundle.
	 * @param bundlePath
	 * 	The path of the resource bundle to wrap.
	 * 	This can be an absolute path (e.g. <js>"com.foo.MyMessages"</js>) or a path relative to the package of the
	 * 	<l>forClass</l> (e.g. <js>"MyMessages"</js> if <l>forClass</l> is <js>"com.foo.MyClass"</js>).
	 */
	public MessageBundle(Class<?> forClass, String bundlePath) {
		this(forClass, bundlePath, Locale.getDefault());
	}

	private MessageBundle(Class<?> forClass, String bundlePath, Locale locale) {
		this.forClass = forClass;
		this.className = forClass.getSimpleName();
		this.bundlePath = bundlePath;
		this.creationThreadId = Thread.currentThread().getId();
		ClassLoader cl = forClass.getClassLoader();
		ResourceBundle trb = null;
		try {
			trb = ResourceBundle.getBundle(bundlePath, locale, cl);
		} catch (MissingResourceException e) {
			try {
				trb = ResourceBundle.getBundle(forClass.getPackage().getName() + '.' + bundlePath, locale, cl);
			} catch (MissingResourceException e2) {
			}
		}
		this.rb = trb;
		if (rb != null) {

			// Populate keyMap with original mappings.
			for (Enumeration<String> e = getKeys(); e.hasMoreElements();) {
				String key = e.nextElement();
				keyMap.put(key, key);
			}

			// Override/augment with shortname mappings (e.g. "foobar"->"MyClass.foobar")
			String c = className + '.';
			for (Enumeration<String> e = getKeys(); e.hasMoreElements();) {
				String key = e.nextElement();
				if (key.startsWith(c)) {
					String shortKey = key.substring(className.length() + 1);
					keyMap.put(shortKey, key);
				}
			}

			allKeys.addAll(keyMap.keySet());
		}
		searchBundles.add(this);
	}


	/**
	 * Add another bundle path to this resource bundle.
	 * 
	 * <p>
	 * Order of property lookup is first-to-last.
	 * 
	 * <p>
	 * This method must be called from the same thread as the call to the constructor.
	 * This eliminates the need for synchronization.
	 * 
	 * @param forClass The class using this resource bundle.
	 * @param bundlePath The bundle path.
	 * @return This object (for method chaining).
	 */
	public MessageBundle addSearchPath(Class<?> forClass, String bundlePath) {
		assertSameThread(creationThreadId, "This method can only be called from the same thread that created the object.");
		MessageBundle srb = new MessageBundle(forClass, bundlePath);
		if (srb.rb != null) {
			allKeys.addAll(srb.keySet());
			searchBundles.add(srb);
		}
		return this;
	}

	@Override /* ResourceBundle */
	public boolean containsKey(String key) {
		return allKeys.contains(key);
	}

	/**
	 * Similar to {@link ResourceBundle#getString(String)} except allows you to pass in {@link MessageFormat} objects.
	 * 
	 * @param key The resource bundle key.
	 * @param args Optional {@link MessageFormat}-style arguments.
	 * @return
	 * 	The resolved value.  Never <jk>null</jk>.
	 * 	<js>"{!!key}"</js> if the bundle is missing.
	 * 	<js>"{!key}"</js> if the key is missing.
	 */
	public String getString(String key, Object...args) {
		String s = getString(key);
		if (s.length() > 0 && s.charAt(0) == '{')
			return s;
		return format(s, args);
	}

	/**
	 * Same as {@link #getString(String, Object...)} but allows you to specify the locale.
	 * 
	 * @param locale The locale of the resource bundle to retrieve message from.
	 * @param key The resource bundle key.
	 * @param args Optional {@link MessageFormat}-style arguments.
	 * @return
	 * 	The resolved value.  Never <jk>null</jk>.
	 * 	<js>"{!!key}"</js> if the bundle is missing.
	 * 	<js>"{!key}"</js> if the key is missing.
	 */
	public String getString(Locale locale, String key, Object...args) {
		if (locale == null)
			return getString(key, args);
		return getBundle(locale).getString(key, args);
	}

	/**
	 * Same as {@link #getString(String, Object...)} but uses the locale specified on the call to {@link #setClientLocale(Locale)}.
	 * 
	 * @param key The resource bundle key.
	 * @param args Optional {@link MessageFormat}-style arguments.
	 * @return
	 * 	The resolved value.  Never <jk>null</jk>.
	 * 	<js>"{!!key}"</js> if the bundle is missing.
	 * 	<js>"{!key}"</js> if the key is missing.
	 */
	public String getClientString(String key, Object...args) {
		return getString(clientLocale.get(), key, args);
	}

	/**
	 * Looks for all the specified keys in the resource bundle and returns the first value that exists.
	 * 
	 * @param keys The list of possible keys.
	 * @return The resolved value, or <jk>null</jk> if no value is found or the resource bundle is missing.
	 */
	public String findFirstString(String...keys) {
		if (rb == null)
			return null;
		for (String k : keys) {
			if (containsKey(k))
				return getString(k);
		}
		return null;
	}

	/**
	 * Same as {@link #findFirstString(String...)}, but uses the specified locale.
	 * 
	 * @param locale The locale of the resource bundle to retrieve message from.
	 * @param keys The list of possible keys.
	 * @return The resolved value, or <jk>null</jk> if no value is found or the resource bundle is missing.
	 */
	public String findFirstString(Locale locale, String...keys) {
		MessageBundle srb = getBundle(locale);
		return srb.findFirstString(keys);
	}

	@Override /* ResourceBundle */
	public Set<String> keySet() {
		return Collections.unmodifiableSet(allKeys);
	}

	/**
	 * Returns all keys in this resource bundle with the specified prefix.
	 * 
	 * @param prefix The prefix.
	 * @return The set of all keys in the resource bundle with the prefix.
	 */
	public Set<String> keySet(String prefix) {
		Set<String> set = new HashSet<>();
		for (String s : keySet()) {
			if (s.equals(prefix) || (s.startsWith(prefix) && s.charAt(prefix.length()) == '.'))
				set.add(s);
		}
		return set;
	}

	@Override /* ResourceBundle */
	public Enumeration<String> getKeys() {
		if (rb == null)
			return new Vector<String>(0).elements();
		return rb.getKeys();
	}

	@Override /* ResourceBundle */
	protected Object handleGetObject(String key) {
		for (MessageBundle srb : searchBundles) {
			if (srb.rb != null) {
				String key2 = srb.keyMap.get(key);
				if (key2 != null) {
					try {
						return srb.rb.getObject(key2);
					} catch (Exception e) {
						return "{!"+key+"}";
					}
				}
			}
		}
		if (rb == null)
			return "{!!"+key+"}";
		return "{!"+key+"}";
	}

	/**
	 * Returns this resource bundle as an {@link ObjectMap}.
	 * 
	 * <p>
	 * Useful for debugging purposes.
	 * Note that any class that implements a <code>swap()</code> method will automatically be serialized by
	 * calling this method and serializing the result.
	 * 
	 * <p>
	 * This method always constructs a new {@link ObjectMap} on each call.
	 * 
	 * @return A new map containing all the keys and values in this bundle.
	 */
	public ObjectMap swap() {
		ObjectMap om = new ObjectMap();
		for (String k : allKeys)
			om.put(k, getString(k));
		return om;
	}

	/**
	 * Returns the resource bundle for the specified locale.
	 * 
	 * @param locale The client locale.
	 * @return The resource bundle for the specified locale.  Never <jk>null</jk>.
	 */
	public MessageBundle getBundle(Locale locale) {
		MessageBundle mb = localizedBundles.get(locale);
		if (mb != null)
			return mb;
		mb = new MessageBundle(forClass, bundlePath, locale);
		List<MessageBundle> l = new ArrayList<>(searchBundles.size()-1);
		for (int i = 1; i < searchBundles.size(); i++) {
			MessageBundle srb = searchBundles.get(i);
			srb = new MessageBundle(srb.forClass, srb.bundlePath, locale);
			l.add(srb);
			mb.allKeys.addAll(srb.keySet());
		}
		mb.searchBundles.addAll(l);
		localizedBundles.putIfAbsent(locale, mb);
		return localizedBundles.get(locale);
	}
}