/* LabelLoader.java

	Purpose:

	Description:

	History:
		Mon Jun 12 13:05:05     2006, Created by tomyeh

Copyright (C) 2006 Potix Corporation. All Rights Reserved.

{{IS_RIGHT
}}IS_RIGHT
*/
package org.zkoss.util.resource.impl;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
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.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zkoss.lang.Library;
import org.zkoss.lang.SystemException;
import org.zkoss.util.FilterMap;
import org.zkoss.util.Locales;
import org.zkoss.util.Maps;
import org.zkoss.util.WaitLock;
import org.zkoss.util.resource.ClassLocator;
import org.zkoss.util.resource.LabelLocator;
import org.zkoss.util.resource.LabelLocator2;
import org.zkoss.xel.Expression;
import org.zkoss.xel.ExpressionFactory;
import org.zkoss.xel.Expressions;
import org.zkoss.xel.VariableResolver;
import org.zkoss.xel.VariableResolverX;
import org.zkoss.xel.util.SimpleXelContext;

/**
 * The label loader (implementation only).
 *
 * Used to implement {@link org.zkoss.util.resource.Labels}.
 *
 * <p>Notice that the encoding of the Locale dependent file (*.properties)
 * is assumed to be UTF-8. If it is not the case, please refer to
 * <a href="http://books.zkoss.org/wiki/ZK_Configuration_Reference/zk.xml/The_library-property_Element/Library_Properties#org.zkoss.util.label.web.charset">ZK Configuration Reference</a>
 * for more information.
 * <p> Specify the library property of <code>org.zkoss.util.resource.LabelLoader.class</code>
 * in zk.xml to provide a customized label loader for debugging purpose. (since 7.0.1)
 * @author tomyeh
 */
public class LabelLoaderImpl implements LabelLoader {
	private static final Logger log = LoggerFactory.getLogger(LabelLoaderImpl.class);

	/** A map of (Locale l, Map(String key, ExValue label)).
	 * We use two maps to speed up the access of labels.
	 * _labels allows concurrent access without synchronization.
	 * _syncLabels requires synchronization and used for update.
	 */
	private Map<Locale, Map<String, ExValue>> _labels = Collections.emptyMap();
	/** A map of (Locale 1, Map<String key1, Map<String key2...> or ExValue label>)
	 * It is used by variable resolver and allows ${labels.key1.key2}.
	 * _segLabels allows concurrent access without synchronization.
	 * See also {@link #getSegmentedLabels}.
	 */
	private Map<Locale, Map<String, Object>> _segLabels = Collections.emptyMap();
	/** Map<Locale, Map<String, String>>.
	 */
	private final Map<Locale, Object> _syncLabels = new HashMap<Locale, Object>(8);
	/** A set of LabelLocator or LabelLocator2. */
	private final Set<Object> _locators = new LinkedHashSet<Object>(4); //order is important
	/** The XEL context. */
	private final SimpleXelContext _xelc;
	private String _jarcharset, _warcharset;
	private final ExpressionFactory _expf;
	private final FilterMap.Filter _fmfilter;

	public LabelLoaderImpl() {
		_fmfilter = new FilterMap.Filter() {
			public Object filter(Object key, Object value) {
				Object o = value instanceof ExValue ?
					((ExValue)value).getValue(): value;
				if (o == null) {
					// it's possible like missing a label.
					o = handleMissingLabel(key);
				}
				return o;
			}
		};
		_expf = Expressions.newExpressionFactory();
		_xelc = new SimpleXelContext(new Resolver(), null);
	}

	/**
	 * Handles the missing label. The default action is logging a message.
	 *
	 * @param key the specified key
	 * @return the fallback value, or null if no fallback value available
	 * @since 8.6.0
	 */
	protected Object handleMissingLabel(Object key) {
		if (log.isDebugEnabled()) {
			log.debug("The key of [{}] is not found in labels!", key);
		}
		return null;
	}

	/** Returns the label of the specified key for the current locale,
	 * or null if not found.
	 * @see #getSegmentedLabels
	 */
	public String getLabel(String key) {
		return getLabel(Locales.getCurrent(), key);
	}
	/** Returns the label of the specified key for the specified locale,
	 * or null if not found.
	 * @since 5.0.7
	 */
	public String getLabel(Locale locale, String key) {
		Map<String, ExValue> map = _labels.get(locale);
		if (map == null)
			map = loadLabels(locale);
		final ExValue exVal = map.get(key);
		return exVal != null ? exVal.getValue(): null;
	}
	/** Returns a map of segmented labels for the current locale (never null).
	 * Unlike {@link #getLabel}, if a key of the label contains dot, it will
	 * be split into multiple keys and then grouped into map. It is so-called
	 * segmented.
	 * <p>For example, the following property file will parsed into a couple of maps,
	 * and <code>getSegmentedLabels()</code> returns a map containing
	 * a single entry. The entry's key is <code>"a"</code> and the value
	 * is another map with two entries <code>"b"</code> and <code>"c"</code>.
	 * And, the value for <code>"b"</code> is another two-entries map (containing
	 * <code>"c"</code> and <code>"d"</code>).
	 * <pre><code>
	 * a.b.c=1
	 * a.b.d=2
	 * a.e=3</pre></code>
	 * <p>This method is designed to make labels easier to be accessed in
	 * EL expressions.
	 * <p>On the other hand, {@link #getLabel} does not split them, and
	 * you could access them by, say, <code>getLabel("a.b.d")</code>.
	 * @since 5.0.7
	 */
	public Map<String, Object> getSegmentedLabels() {
		return getSegmentedLabels(Locales.getCurrent());
	}
	/** Returns a map of segmented labels for the specified locale (never null).
	 * Refer to {@link #getSegmentedLabels()} for details.
	 * @since 5.0.7
	 */
	public Map<String, Object> getSegmentedLabels(Locale locale) {
		final Map<String, Object> map = _segLabels.get(locale);
		if (map != null)
			return map;
		loadLabels(locale);
		return _segLabels.get(locale);
	}

	/** Sets the variable resolver, which is used if an EL expression
	 * is specified.
	 *
	 * @since 3.0.0
	 */
	public VariableResolver setVariableResolver(VariableResolver resolv) {
		final Resolver resolver = (Resolver)_xelc.getVariableResolver();
		final VariableResolver old = resolver.custom;
		resolver.custom = resolv;
		return old;
	}
	/** Registers a locator which is used to load the Locale-dependent labels
	 * from other resource, such as servlet contexts.
	 */
	public void register(LabelLocator locator) {
		register0(locator);
	}
	/** Registers a locator which is used to load the Locale-dependent labels
	 * from other resource, such as database.
	 * @since 5.0.5
	 */
	public void register(LabelLocator2 locator) {
		register0(locator);
	}
	private void register0(Object locator) {
		if (locator == null)
			throw new NullPointerException("locator");

		synchronized (_locators) {
			if (!_locators.add(locator))
				log.warn("Replace the old one, because it is replicated: "+locator);
		}

		reset(); //Labels might be loaded before, so...
	}
	/** Resets all cached labels and next call to {@link #getLabel}
	 * will cause re-loading the Locale-dependent labels.
	 */
	public void reset() {
		synchronized (_syncLabels) {
			_syncLabels.clear();
			_segLabels = Collections.emptyMap();
			_labels = Collections.emptyMap();
		}
	}

	//-- private utilities --//
	/** Returns Map(String key, ExValue label) of the specified locale.
	 */
	@SuppressWarnings("unchecked")
	private final Map<String, ExValue> loadLabels(Locale locale) {
		WaitLock lock = null;
		for (;;) {
			final Object o;
			synchronized (_syncLabels) {
				o = _syncLabels.get(locale);
				if (o == null)
					_syncLabels.put(locale, lock = new WaitLock()); //lock it
			}

			if (o instanceof Map)
				return (Map)o;
			if (o == null)
				break; //go to load the page

			//wait because some one is creating the servlet
			if (!((WaitLock)o).waitUntilUnlock(5*60*1000))
				log.warn("Take too long to wait loading labels: "+locale
					+"\nTry to load again automatically...");
		} //for(;;)

		if (_jarcharset == null)
			_jarcharset = Library.getProperty("org.zkoss.util.label.classpath.charset", "UTF-8");
		if (_warcharset == null) {
			_warcharset = Library.getProperty("org.zkoss.util.label.web.charset", null);
			if (_warcharset == null)
				_warcharset = Library.getProperty("org.zkoss.util.label.WEB-INF.charset", "UTF-8"); //backward compatible
		}

		try {
			//get the class name
			if (locale != null)
				log.info("Loading labels for "+locale);
			Map labels = new HashMap(512);

			//1. load from modules
			final ClassLocator locator = new ClassLocator();
			for (Enumeration en = locator.getResources(
				locale == null ? "metainfo/zk-label.properties":
				"metainfo/zk-label_" + locale + ".properties");
			en.hasMoreElements();) {
				final URL url = (URL)en.nextElement();
				load(labels, url, _jarcharset);
			}

			//2. load from extra resource
			final List locators;
			synchronized (_locators) {
				locators = new LinkedList(_locators);
			}
			for (Iterator it = locators.iterator(); it.hasNext();) {
				Object o = it.next();
				if (o instanceof LabelLocator) {
					final URL url = ((LabelLocator)o).locate(locale);
					if (url != null)
						load(labels, url, _warcharset);
				} else {
					final LabelLocator2 loc = (LabelLocator2)o;
					final InputStream is = loc.locate(locale);
					if (is != null) {
						final String cs = loc.getCharset();
						load(labels, is, cs != null ? cs: _warcharset);
					}
				}
			}

			//Convert values to ExValue
			toExValue(labels);

			//merge with labels from 'super' locale
			if (locale != null) {
				final String lang = locale.getLanguage();
				final String cnty = locale.getCountry();
				final String var = locale.getVariant();
				final Map superlabels = loadLabels(
					var != null && var.length() > 0 ? new Locale(lang, cnty):
					cnty != null && cnty.length() > 0 ? new Locale(lang, ""): null);
				if (labels.isEmpty()) {
					labels = superlabels.isEmpty() ?
						Collections.EMPTY_MAP: superlabels;
				} else if (!superlabels.isEmpty()) {
					Map combined = new HashMap(superlabels);
					combined.putAll(labels);
					labels = combined;
				}
			}

			//add to map
			synchronized (_syncLabels) {
				_syncLabels.put(locale, labels);
				cloneLables();
			}
			return labels;
		} catch (Throwable ex) {
			synchronized (_syncLabels) {
				_syncLabels.remove(locale);
				cloneLables();
			}
			throw SystemException.Aide.wrap(ex);
		} finally {
			lock.unlock(); //unlock (always unlock to avoid deadlock)
		}
	}
	@SuppressWarnings("unchecked")
	private void toExValue(Map labels) {
		if (!labels.isEmpty())
			for (Iterator it = labels.entrySet().iterator(); it.hasNext();) {
				final Map.Entry me = (Map.Entry) it.next();
				String value = expendValue(labels, (String) me.getValue());
				me.setValue(new ExValue(value));
			}
	}

	//expend ${} EL in labels recursively
	private String expendValue(Map labels, String value) {
		if (labels != null && value != null) {
			int offset = 0;
			while (offset < value.length()) {
				int start = value.indexOf("${", offset);
				if (start != -1) {
					int end = value.indexOf("}", start);
					if (end != -1) {
						String exp = value.substring(start, end + 1);
						String expStr = exp.substring(2, exp.length() - 1);
						if (expStr.endsWith(".$"))
							expStr = expStr.substring(0, expStr.length() - 2);
						Object expend = labels.get(expStr);
						String expended = "";
						if (expend instanceof String)
							expended = expendValue(labels, (String) expend);
						else if (expend instanceof ExValue)
							expended = expendValue(labels, ((ExValue) expend).getValue());
						value = value.substring(0, start) + expended + value.substring(start + exp.length());
						offset = start + expended.length();
					} else {
						break;
					}
				} else {
					break;
				}
			}
		}
		return value;
	}

	//Copy _syncLabels to _labels. It must be called in synchronized(_syncLabels)
	@SuppressWarnings("unchecked")
	private void cloneLables() {
		final Map labels = new HashMap(),
			segLabels = new HashMap();
		for (Iterator it = _syncLabels.entrySet().iterator(); it.hasNext();) {
			final Map.Entry me = (Map.Entry)it.next();
			final Object value = me.getValue();
			if (value instanceof Map) {
				final Object key = me.getKey();
				labels.put(key, value);
				segLabels.put(key, segment((Map)value));
			}
		}
		_labels = labels;
		_segLabels = segLabels;
	}
	@SuppressWarnings("unchecked")
	private Map segment(Map map) {
		for (Iterator it = map.keySet().iterator(); it.hasNext();) {
			final String key = (String)it.next();
			if (key.indexOf(".") >= 0)
				return segmentInner(new HashMap(map)); //clone since we'll modify it
		}
		return new FilterMap(map, _fmfilter); //no special key
	}
	@SuppressWarnings("unchecked")
	private FilterMap segmentInner(Map map) {
		final Map segFound = new HashMap();
		for (Iterator it = map.entrySet().iterator(); it.hasNext();) {
			final Map.Entry me = (Map.Entry)it.next();
			final String key = (String)me.getKey();
			final Object val = me.getValue();
			final int index = key.indexOf('.');
			if (index >= 0) {
				it.remove(); //remove it

				final String newkey = key.substring(0, index);
				Map vals = (Map)segFound.get(newkey);
				if (vals == null)
					segFound.put(newkey, vals = new HashMap());
				vals.put(key.substring(index + 1), val);
			}
		}

		for (Iterator it = segFound.entrySet().iterator(); it.hasNext();) {
			final Map.Entry me = (Map.Entry)it.next();
			final FilterMap seged;
			Object o = map.put(me.getKey(), seged = segmentInner((Map)me.getValue()));
			if (o != null && !(o instanceof Map)/*just in case*/) {
				final Map m = seged.getOrigin();
				o = m.put("$", o);
				if (o != null)
					m.put("$", o); //restore
			}
		}
		return new FilterMap(map, _fmfilter);
	}

	/** Loads all labels from the specified URL. */
	private static final void load(Map<String, String> labels, URL url, String charset)
	throws IOException {
		log.info("Opening "+url); //don't use MCommon since Messages might call getLabel
		load(labels, url.openStream(), charset);
	}
	/** Loads all labels from the specified URL. */
	private static final void load(Map<String, String> labels, InputStream is, String charset)
	throws IOException {
		final Map<String, String> news = new HashMap<String, String>();
		try {
			Maps.load(news, is, charset);
		} finally {
			try {is.close();} catch (Throwable ex) {}
		}
		for (Map.Entry<String, String> me: news.entrySet()) {
			labels.put(me.getKey(), me.getValue());
		}
	}
	private class ExValue {
		private Expression _expr;
		private String _val;
		public ExValue(String val) {
			int j;
			if ((j = val.indexOf("${")) >= 0 && val.indexOf('}', j + 2) >= 0) {
				try {
					_expr = _expf.parseExpression(_xelc, val, String.class);
					return;
				} catch (Throwable ex) {
					log.error("Illegal expression: "+val, ex);
				}
			}
			_expr = null;
			_val = val;
		}
		public String getValue() {
			return _expr != null ? (String)_expr.evaluate(_xelc): _val;
		}
	}
	private class Resolver implements VariableResolver {
		private VariableResolver custom;
		public Object resolveVariable(String name) {
			if (custom != null) {
				final Object o = custom instanceof VariableResolverX ?
					((VariableResolverX)custom).resolveVariable(null, null, name):
					custom.resolveVariable(name);
				if (o != null)
					return o;
			}
			return getSegmentedLabels().get(name);
		}
	}
}
