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

import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.wicket.core.util.string.interpolator.ConvertingPropertyVariableInterpolator;
import org.apache.wicket.markup.repeater.AbstractRepeater;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.resource.loader.IStringResourceLoader;
import org.apache.wicket.settings.ResourceSettings;
import org.apache.wicket.util.lang.Generics;
import org.apache.wicket.util.string.AppendingStringBuffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * A utility class that encapsulates all of the localization related functionality in a way that it
 * can be accessed by all areas of the framework in a consistent way. A singleton instance of this
 * class is available via the <code>Application</code> object.
 * <p>
 * You may register additional IStringResourceLoader to extend or replace Wickets default search
 * strategy for the properties. E.g. string resource loaders which load the properties from a
 * database. There should be hardly any need to extend Localizer.
 * 
 * @see org.apache.wicket.settings.ResourceSettings#getLocalizer()
 * @see org.apache.wicket.resource.loader.IStringResourceLoader
 * @see org.apache.wicket.settings.ResourceSettings#getStringResourceLoaders()
 * 
 * @author Chris Turner
 * @author Juergen Donnerstag
 */
public class Localizer
{
	private static final Logger log = LoggerFactory.getLogger(Localizer.class);

	/** ConcurrentHashMap does not allow null values */
	private static final String NULL_VALUE = "<null-value>";

	/** Cache properties */
	private Map<String, String> cache = newCache();

	/** Database that maps class names to an integer id. */
	private final ClassMetaDatabase metaDatabase = new ClassMetaDatabase();

	/**
	 * @return Same as Application.get().getResourceSettings().getLocalizer()
	 */
	public static Localizer get()
	{
		return Application.get().getResourceSettings().getLocalizer();
	}

	/**
	 * Create the utils instance class backed by the configuration information contained within the
	 * supplied application object.
	 */
	public Localizer()
	{
	}

	/**
	 * Clear all cache entries by instantiating a new cache object
	 * 
	 * @see #newCache()
	 */
	public final void clearCache()
	{
		if (cache != null)
		{
			cache = newCache();
		}
	}

	/**
	 * @see #getString(String, Component, IModel, Locale, String, String)
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component)
		throws MissingResourceException
	{
		return getString(key, component, null, null, null, (String)null);
	}

	/**
	 * @see #getString(String, Component, IModel, Locale, String, String)
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for
	 * @param model
	 *            The model to use for property substitutions in the strings (optional)
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component, final IModel<?> model)
		throws MissingResourceException
	{
		return getString(key, component, model, null, null, (String)null);
	}

	/**
	 * @see #getString(String, Component, IModel, Locale, String, String)
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component, final String defaultValue)
		throws MissingResourceException
	{
		return getString(key, component, null, null, null, defaultValue);
	}

	/**
	 * @see #getString(String, Component, IModel, Locale, String, String)
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for
	 * @param model
	 *            The model to use for property substitutions in the strings (optional)
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component, final IModel<?> model,
		final String defaultValue) throws MissingResourceException
	{
		return getString(key, component, model, null, null, defaultValue);
	}

	/**
	 * Get the localized string using all of the supplied parameters. This method is left public to
	 * allow developers full control over string resource loading. However, it is recommended that
	 * one of the other convenience methods in the class are used as they handle all of the work
	 * related to obtaining the current user locale and style information.
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for (optional)
	 * @param model
	 *            The model to use for substitutions in the strings (optional)
	 * @param locale
	 *            If != null, it'll supersede the component's locale
	 * @param style
	 *            If != null, it'll supersede the component's style
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component, final IModel<?> model,
		final Locale locale, final String style, final String defaultValue)
		throws MissingResourceException
	{
		IModel<String> defaultValueModel = defaultValue != null ? Model.of(defaultValue) : null;
		return getString(key, component, model, locale, style, defaultValueModel);
	}

	/**
	 * Get the localized string using all of the supplied parameters. This method is left public to
	 * allow developers full control over string resource loading. However, it is recommended that
	 * one of the other convenience methods in the class are used as they handle all of the work
	 * related to obtaining the current user locale and style information.
	 *
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for (optional)
	 * @param model
	 *            The model to use for substitutions in the strings (optional)
	 * @param locale
	 *            If != null, it'll supersede the component's locale
	 * @param style
	 *            If != null, it'll supersede the component's style
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 * @throws MissingResourceException
	 *             If resource not found and configuration dictates that exception should be thrown
	 */
	public String getString(final String key, final Component component, final IModel<?> model,
	                        final Locale locale, final String style, final IModel<String> defaultValue)
			throws MissingResourceException
	{
		final ResourceSettings resourceSettings = Application.get().getResourceSettings();

		String value = getStringIgnoreSettings(key, component, model, locale, style, null);

		// If a property value has been found, or a default value was given,
		// then replace the placeholder and we are done
		if (value != null)
		{
			return value;
		}
		else if (defaultValue != null && resourceSettings.getUseDefaultOnMissingResource())
		{
			// Resource not found, so handle missing resources based on
			// application configuration and try the default value
			value = defaultValue.getObject();

			if (value != null)
			{
				// If a property value has been found, or a default value was given,
				// then replace the placeholder and we are done
				return substitutePropertyExpressions(component, value, model);
			}
		}

		if (resourceSettings.getThrowExceptionOnMissingResource())
		{
			AppendingStringBuffer message = new AppendingStringBuffer("Unable to find property: '");
			message.append(key);
			message.append('\'');

			if (component != null)
			{
				message.append(" for component: ");
				message.append(component.getPageRelativePath());
				message.append(" [class=").append(component.getClass().getName()).append(']');
			}
			message.append(". Locale: ").append(locale).append(", style: ").append(style);

			throw new MissingResourceException(message.toString(), (component != null
				? component.getClass().getName() : ""), key);
		}

		return "[Warning: Property for '" + key + "' not found]";
	}

	/**
	 * @see #getStringIgnoreSettings(String, Component, IModel, Locale, String, String)
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for (optional)
	 * @param model
	 *            The model to use for substitutions in the strings (optional)
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 */
	public String getStringIgnoreSettings(final String key, final Component component,
		final IModel<?> model, final String defaultValue)
	{
		return getStringIgnoreSettings(key, component, model, null, null, defaultValue);
	}

	/**
	 * This is similar to {@link #getString(String, Component, IModel, String)} except that the
	 * resource settings are ignored. This allows to to code something like
	 * 
	 * <pre>
	 * String option = getLocalizer().getStringIgnoreSettings(getId() + &quot;.null&quot;, this, &quot;&quot;);
	 * if (Strings.isEmpty(option))
	 * {
	 * 	option = getLocalizer().getString(&quot;null&quot;, this, CHOOSE_ONE);
	 * }
	 * </pre>
	 * 
	 * @param key
	 *            The key to obtain the resource for
	 * @param component
	 *            The component to get the resource for (optional)
	 * @param model
	 *            The model to use for substitutions in the strings (optional)
	 * @param locale
	 *            If != null, it'll supersede the component's locale
	 * @param style
	 *            If != null, it'll supersede the component's style
	 * @param defaultValue
	 *            The default value (optional)
	 * @return The string resource
	 */
	public String getStringIgnoreSettings(final String key, final Component component,
		final IModel<?> model, Locale locale, String style, final String defaultValue)
	{
		boolean addedToPage = false;
		if (component != null)
		{
			if ((component instanceof Page) || (null != component.findParent(Page.class)))
			{
				addedToPage = true;
			}

			if (!addedToPage && log.isWarnEnabled())
			{
				log.warn(
					"Tried to retrieve a localized string for a component that has not yet been added to the page. "
						+ "This can sometimes lead to an invalid or no localized resource returned. "
						+ "Make sure you are not calling Component#getString() inside your Component's constructor. "
						+ "Offending component: {} - Resource key: {}", component, key);
			}
		}

		String cacheKey = null;
		String value;

		// Make sure locale, style and variation have the right values
		String variation = (component != null ? component.getVariation() : null);

		if ((locale == null) && (component != null))
		{
			locale = component.getLocale();
		}
		if (locale == null)
		{
			locale = Session.exists() ? Session.get().getLocale() : Locale.getDefault();
		}

		if ((style == null) && (component != null))
		{
			style = component.getStyle();
		}
		if (style == null)
		{
			style = Session.exists() ? Session.get().getStyle() : null;
		}

		// If this component is not yet added to page we do not want to check
		// cache as we can generate an invalid cache key
		if ((cache != null) && ((component == null) || addedToPage))
		{
			cacheKey = getCacheKey(key, component, locale, style, variation);
		}

		// Value not found are cached as well (value = null)
		if ((cacheKey != null) && cache.containsKey(cacheKey))
		{
			value = getFromCache(cacheKey);
			if (log.isDebugEnabled())
			{
				log.debug("Property found in cache: '" + key + "'; Component: '" +
					(component != null ? component.toString(false) : null) + "'; value: '" + value +
					'\'');
			}
		}
		else
		{
			if (log.isDebugEnabled())
			{
				log.debug("Locate property: key: '" + key + "'; Component: '" +
					(component != null ? component.toString(false) : null) + '\'');
			}

			// Iterate over all registered string resource loaders until the property has been found
			Iterator<IStringResourceLoader> iter = getStringResourceLoaders().iterator();
			value = null;
			while (iter.hasNext() && (value == null))
			{
				IStringResourceLoader loader = iter.next();
				value = loader.loadStringResource(component, key, locale, style, variation);
			}

			// Cache the result incl null if not found
			if (cacheKey != null)
			{
				putIntoCache(cacheKey, value);
			}

			if ((value == null) && log.isDebugEnabled())
			{
				log.debug("Property not found; key: '" + key + "'; Component: '" +
					(component != null ? component.toString(false) : null) + '\'');
			}
		}

		if (value == null)
		{
			value = defaultValue;
		}

		// If a property value has been found, or a default value was given,
		// then replace the placeholder and we are done
		if (value != null)
		{
			return substitutePropertyExpressions(component, value, model);
		}

		return null;
	}

	/**
	 * In case you want to provide your own list of string resource loaders
	 * 
	 * @return List of string resource loaders
	 */
	protected List<IStringResourceLoader> getStringResourceLoaders()
	{
		return Application.get().getResourceSettings().getStringResourceLoaders();
	}

	/**
	 * Put the value into the cache and associate it with the cache key
	 * 
	 * @param cacheKey
	 * @param string
	 */
	protected void putIntoCache(final String cacheKey, final String string)
	{
		if (cache == null)
		{
			return;
		}

		// ConcurrentHashMap does not allow null values
		if (string == null)
		{
			cache.put(cacheKey, NULL_VALUE);
		}
		else
		{
			cache.put(cacheKey, string);
		}
	}

	/**
	 * Get the value associated with the key from the cache.
	 * 
	 * @param cacheKey
	 * @return The value of the key
	 */
	protected String getFromCache(final String cacheKey)
	{
		if (cache == null)
		{
			return null;
		}

		final String value = cache.get(cacheKey);

		// ConcurrentHashMap does not allow null values
		if (NULL_VALUE == value)
		{
			return null;
		}
		return value;
	}

	/**
	 * Gets the cache key
	 * 
	 * @param key
	 * @param component
	 * @param locale
	 *            Guaranteed to be != null
	 * @param style
	 * @param variation
	 * @return The value of the key
	 */
	protected String getCacheKey(final String key, final Component component, final Locale locale,
		final String style, final String variation)
	{
		if (component != null)
		{
			StringBuilder buffer = new StringBuilder(200);
			buffer.append(key);

			Component cursor = component;

			while (cursor != null)
			{
				buffer.append('-').append(metaDatabase.id(cursor.getClass()));

				if (cursor instanceof Page)
				{
					break;
				}

				/*
				 * only append component id if component is not a loop item because (a) these ids
				 * are irrelevant when generating resource cache keys (b) they cause a lot of
				 * redundant keys to be generated
				 * 
				 * also if the cursor component is an auto component we append a constant string
				 * instead of component's id because auto components have a newly generated id on
				 * every render.
				 */
				final Component parent = cursor.getParent();
				final boolean skip = parent instanceof AbstractRepeater;

				if (skip == false)
				{
					String cursorKey = cursor.isAuto() ? "wicket-auto" : cursor.getId();
					buffer.append(':').append(cursorKey);
				}

				cursor = parent;
			}

			buffer.append('-').append(locale);
			buffer.append('-').append(style);
			buffer.append('-').append(variation);

			return buffer.toString();
		}
		else
		{
			// locale is guaranteed to be != null
			return key + '-' + locale.toString() + '-' + style;
		}
	}

/**
	 * Helper method to handle property variable substitution in strings.
	 * 
	 * @param component
	 *            The component requesting a model value or {@code null}
	 * @param string
	 *            The string to substitute into
	 * @param model
	 *            The model
	 * @return The resulting string
	 */
	public String substitutePropertyExpressions(final Component component, final String string,
		final IModel<?> model)
	{
		if ((string != null) && (model != null))
		{
			final IConverterLocator locator;
			final Locale locale;
			if (component == null)
			{
				locator = Application.get().getConverterLocator();

				if (Session.exists())
				{
					locale = Session.get().getLocale();
				}
				else
				{
					locale = Locale.getDefault();
				}
			}
			else
			{
				locator = component;
				locale = component.getLocale();
			}

			return new ConvertingPropertyVariableInterpolator(string, model.getObject(), locator,
				locale).toString();
		}
		return string;
	}

	/**
	 * By default the cache is enabled. Disabling the cache will disable it and clear the cache.
	 * This can be handy for example in development mode.
	 * 
	 * @param value
	 */
	public final void setEnableCache(boolean value)
	{
		if (value == false)
		{
			cache = null;
		}
		else if (cache == null)
		{
			cache = newCache();
		}
	}

	/**
	 * Create a new cache, override this method if you want a different map to store the cache keys,
	 * for example a map that hold only the last X number of elements..
	 * 
	 * By default it uses the {@link ConcurrentHashMap}
	 * 
	 * @return cache
	 */
	protected Map<String, String> newCache()
	{
		return new ConcurrentHashMap<>();
	}

	/**
	 * Database that maps class names to an integer id. This is used to make localizer keys shorter
	 * because sometimes they can contain a large number of class names.
	 * 
	 * @author igor.vaynberg
	 */
	private static class ClassMetaDatabase
	{
		private final ConcurrentMap<String, Long> nameToId = Generics.newConcurrentHashMap();
		private final AtomicLong nameCounter = new AtomicLong();

		/**
		 * Returns a unique id that represents this class' name. This can be used for compressing
		 * class names. Notice this id should not be used across cluster nodes.
		 * 
		 * @param clazz
		 * @return long id of class name
		 */
		public long id(Class<?> clazz)
		{
			final String name = clazz.getName();
			Long id = nameToId.get(name);
			if (id == null)
			{
				id = nameCounter.incrementAndGet();
				Long previousId = nameToId.putIfAbsent(name, id);
				if (previousId != null)
				{
					id = previousId;
				}
			}
			return id;
		}
	}
}
