/* Devices.java

	Purpose:
		
	Description:
		
	History:
		Tue May 15 14:09:16     2007, Created by tomyeh

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

{{IS_RIGHT
	This program is distributed under LGPL Version 2.1 in the hope that
	it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.zk.device;

import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.zkoss.idom.Element;
import org.zkoss.idom.util.IDOMs;
import org.zkoss.io.Files;
import org.zkoss.lang.Classes;
import org.zkoss.zk.mesg.MZk;
import org.zkoss.zk.ui.Execution;
import org.zkoss.zk.ui.UiException;
import org.zkoss.zk.ui.WebApps;
import org.zkoss.zk.ui.sys.ServerPush;

/**
 * A manager of devices ({@link Device}).
 *
 * @author tomyeh
 * @since 2.4.0
 */
public class Devices {
	private Devices() {
	}

	/** Map(String type, DeviceInfo info or Device device). */
	private static final Map<String, Object> _devmap = new LinkedHashMap<String, Object>(4);
	/** A list of devices registered. It is a duplicated info (of _devmap)
	 * but used to improve the performance.
	 */
	private static Device[] _devs = new Device[0];

	/** Returns the device for the specified desktop type.
	 *
	 * @param deviceType the device type, such as ajax, xml and mil.
	 * @exception DeviceNotFoundException if not found.
	 * @since 3.0.0
	 */
	public static final Device getDevice(String deviceType) throws DeviceNotFoundException {
		final Object o; //null, Device or DeviceInfo
		synchronized (_devmap) {
			o = _devmap.get(deviceType);
		}

		if (o instanceof Device)
			return (Device) o;
		if (o == null)
			throw new DeviceNotFoundException(deviceType, MZk.NOT_FOUND, deviceType);

		final Device device = ((DeviceInfo) o).newDevice(deviceType);
		final List<Device> devs = new LinkedList<Device>();
		synchronized (_devmap) {
			final Object old = _devmap.put(deviceType, device);
			if (old != o)
				_devmap.put(deviceType, old); //changed by someone else; so restore

			for (Object d : _devmap.values()) {
				if (d instanceof Device)
					devs.add((Device) d);
			}
		}
		_devs = devs.toArray(new Device[devs.size()]);
		return device;
	}

	/** Returns the device for the specified client.
	 * It invokes {@link Device#isCompatible} to return the correct device.
	 *If all devices returns null (means unknown), one of the devices returning
	 *null is returned. If all devices returns Boolean.FALSE,
	 *DeviceNotFoundException is thrown.
	 *
	 * @param userAgent represents a client.
	 * It is the user-agent header for HTTP-base client.
	 * @see org.zkoss.zk.ui.Execution#getUserAgent
	 * @exception DeviceNotFoundException if not found.
	 * @since 3.0.0
	 */
	public static final Device getDeviceByClient(String userAgent) throws DeviceNotFoundException {
		String[] devTypes;
		synchronized (_devmap) {
			Collection<String> c = _devmap.keySet();
			devTypes = c.toArray(new String[c.size()]);
		}

		Device device = null;
		for (int j = 0; j < devTypes.length; ++j) {
			Device dev;
			try {
				dev = getDevice(devTypes[j]);
			} catch (Throwable ex) {
				continue; //skip
			}

			Boolean b;
			try {
				b = dev.isCompatible(userAgent);
			} catch (Throwable ex) { //backward compatible
				b = null;
			}

			if (b != null) {
				if (b.booleanValue())
					return dev;
			} else if (device == null || "ajax".equals(devTypes[j]) //ajax highest priority
					|| "xml".equals(device.getType())) { //xml lowest priority
				device = dev;
			}
		}
		if (device == null)
			throw new DeviceNotFoundException(userAgent, MZk.NOT_FOUND, userAgent);
		return device;
	}

	/** Tests whether the device for the specified type exists.
	 *
	 * @param deviceType the device type, say, ajax.
	 * @since 2.4.0
	 */
	public static final boolean exists(String deviceType) {
		if (deviceType == null)
			return false;

		final Object o;
		synchronized (_devmap) {
			o = _devmap.get(deviceType);
		}
		return o instanceof Device || (o != null && ((DeviceInfo) o).isValid());
	}

	/** Adds a device based on the XML declaration.
	 *
	 * <pre><code>
	&lt;device-config&gt;
	&lt;device-type&gt;superajax&lt;/device-type&gt;
	&lt;device-class&gt;my.MyDevice&lt;/device-class&gt;
	&lt;unavailable-message&gt;error message&lt;/unavailable-message&gt;
	&lt;server-push-class&gt;my.MyServerPush&lt;/server-push-class&gt;
	&lt;/device-config&gt;
	 * </code></pre>
	 *
	 * @param config the XML element called zscript-config
	 */
	public static final void add(Element config) {
		//Spec: it is OK to declare an nonexistent device
		final String deviceType = IDOMs.getRequiredElementValue(config, "device-type");

		String s = config.getElementValue("device-class", true);
		if (s != null)
			add(deviceType, s);

		s = config.getElementValue("unavailable-message", true);
		if (s != null)
			setUnavailableMessage(deviceType, s);

		s = config.getElementValue("server-push-class", true);
		if (s != null)
			setServerPushClass(deviceType, s);

		for (Iterator it = config.getElements("embed").iterator(); it.hasNext();) {
			addEmbedded(deviceType, ((Element) it.next()).getText(true));
		}
	}

	/** Adds a device type.
	 *
	 * @param deviceType the device type (a.k.a., the device name).
	 * @param clsnm the device class name
	 * @return the previous class of the device with the same type, if any,
	 * or null if no such device.
	 */
	public static final String add(String deviceType, String clsnm) {
		return add0(deviceType, clsnm);
	}

	/** Adds a device type.
	 *
	 * @param deviceType the device type (a.k.a., the device name).
	 * @param cls the device class
	 * @return the previous class of the device with the same type, if any,
	 * or null if no such device.
	 * @since 3.0.0
	 */
	public static final String add(String deviceType, Class cls) {
		return add0(deviceType, cls);
	}

	private static final String add0(String deviceType, Object cls) {
		if (deviceType == null || deviceType.length() == 0 || cls == null)
			throw new IllegalArgumentException();

		synchronized (_devmap) {
			final Object o = _devmap.get(deviceType);
			if (o instanceof DeviceInfo) {
				return ((DeviceInfo) o).setDeviceClass(cls);
			} else if (o instanceof Device) {
				final Device device = (Device) o;
				_devmap.put(deviceType,
						new DeviceInfo(cls, device.getUnavailableMessage(), device.getServerPushClass()));
				return device.getClass().getName();
			} else {
				_devmap.put(deviceType, new DeviceInfo(cls));
				return null;
			}
		}
	}

	/** Returns the unavailable message for the specified device type.
	 *
	 * <p>The result is the same as the invocation of {@link Device#getUnavailableMessage}
	 * against {@link #getDevice}, but this method will not load the device
	 * if it is not loaded yet.
	 *
	 * @see Device
	 * @see Device#getUnavailableMessage
	 */
	public static final String getUnavailableMessage(String deviceType) {
		final Object o;
		synchronized (_devmap) {
			o = _devmap.get(deviceType);
		}
		return o instanceof Device ? ((Device) o).getUnavailableMessage()
				: o instanceof DeviceInfo ? ((DeviceInfo) o).getUnavailableMessage() : null;
	}

	/** Sets the unavailable message for the specified device type.
	 *
	 * @return the previous unavailable message if any.
	 * @see Device
	 * @see Device#setUnavailableMessage
	 */
	public static final String setUnavailableMessage(String deviceType, String msg) {
		if (deviceType == null || deviceType.length() == 0)
			throw new IllegalArgumentException();

		if (msg != null && msg.length() == 0)
			msg = null;

		synchronized (_devmap) {
			final Object o = _devmap.get(deviceType);
			if (o instanceof Device) {
				return ((Device) o).setUnavailableMessage(msg);
			} else if (o instanceof DeviceInfo) {
				return ((DeviceInfo) o).setUnavailableMessage(msg);
			} else {
				final DeviceInfo info = new DeviceInfo();
				_devmap.put(deviceType, info);
				info.setUnavailableMessage(msg);
				return null;
			}
		}
	}

	/** Returns the content that shall be embedded to the output being
	 * generated to the client, or null if no embedded content.
	 *
	 * @since 3.0.6
	 */
	public String getEmbedded(String deviceType) {
		final Object o;
		synchronized (_devmap) {
			o = _devmap.get(deviceType);
		}
		return o instanceof Device ? ((Device) o).getEmbedded()
				: o instanceof DeviceInfo ? ((DeviceInfo) o).getEmbedded() : null;
	}

	/** Adds the content that shall be added to the output generated and
	 * sent to the client, when rending a desktop.
	 * What content can be embedded depends on the device.
	 * For Ajax devices, it can be anything that can be placed inside
	 * HTML HEAD, such as JavaScript codes.
	 *
	 * <p>As the method name suggests, the embedded contents are accumulated
	 * and all generated to the output.
	 * @since 3.0.6
	 */
	public static void addEmbedded(String deviceType, String content) {
		if (deviceType == null || deviceType.length() == 0)
			throw new IllegalArgumentException();
		if (content == null || content.length() == 0)
			return; //nothing to do

		synchronized (_devmap) {
			final Object o = _devmap.get(deviceType);
			if (o instanceof Device) {
				((Device) o).addEmbedded(content);
			} else if (o instanceof DeviceInfo) {
				((DeviceInfo) o).addEmbedded(content);
			} else {
				final DeviceInfo info = new DeviceInfo();
				_devmap.put(deviceType, info);
				info.addEmbedded(content);
			}
		}
	}

	/** Returns the class name that implements the server push feature.
	 *
	 * <p>Default: null (the server-push feature is not available).
	 *
	 * <p>The result is the same as the invocation of {@link Device#getServerPushClass}
	 * against {@link #getDevice}, but this method will not load the device
	 * if it is not loaded yet.
	 * @since 3.0.0
	 */
	public static final String getServerPushClass(String deviceType) {
		final Object o;
		synchronized (_devmap) {
			o = _devmap.get(deviceType);
		}
		if (o instanceof Device) {
			final Class cls = ((Device) o).getServerPushClass();
			return cls != null ? cls.getName() : null;
		}
		return o instanceof DeviceInfo ? ((DeviceInfo) o).getServerPushClassName() : null;
	}

	/** Sets the name of the class that implements the server-push feature.
	 *
	 * @param clsnm the class name that implements the server push.
	 * If null, it means no server push is available.
	 * @return the previous class name, or null if not available.
	 * @since 3.0.0
	 */
	public static final String setServerPushClass(String deviceType, String clsnm) {
		return setServerPushClass0(deviceType, clsnm);
	}

	/** Sets the class that implements the server-push feature.
	 *
	 * @param cls the class that implements the server push.
	 * If null, it means no server push is available.
	 * @return the previous class name, or null if not available.
	 * @since 3.0.0
	 */
	public static final String setServerPushClass(String deviceType, Class cls) {
		return setServerPushClass0(deviceType, cls);
	}

	private static final String setServerPushClass0(String deviceType, Object cls) {
		if (deviceType == null || deviceType.length() == 0)
			throw new IllegalArgumentException();

		try {
			synchronized (_devmap) {
				final Object o = _devmap.get(deviceType);
				if (o instanceof Device) {
					final Class old = ((Device) o).setServerPushClass(cls instanceof Class ? (Class) cls
							: cls != null ? Classes.forNameByThread((String) cls) : null);
					return old != null ? old.getName() : null;
				} else if (o instanceof DeviceInfo) {
					return ((DeviceInfo) o).setServerPushClass(cls);
				} else {
					final DeviceInfo info = new DeviceInfo();
					_devmap.put(deviceType, info);
					info.setServerPushClass(cls);
					return null;
				}
			}
		} catch (ClassNotFoundException ex) {
			throw new UiException("Class not found: " + cls);
		}
	}

	/** Returns the name and version of the client if the given user agent
	 * matches any of the devices, or null if not matched or it is a standard
	 * browser request.
	 * <p>It iterates all devices and invokes {@link Device#matches} one-by-one,
	 * then returns the first non-null return, if any.
	 * @param userAgent represents a client (i.e., HTTP's user-agent).
	 * @return a pair of objects or null.
	 * The first element of the pair is the name of the client (String),
	 * the second element is the version (Double, such as 3.5).
	 * @since 6.0.0
	 */
	public static final Object[] matches(String userAgent) {
		for (int j = 0; j < _devs.length; ++j) {
			Object[] inf = _devs[j].matches(userAgent);
			if (inf != null)
				return inf;
		}
		return null;
	}

	/** Loads the content of a javascript file as a String.
	 * @since 5.0.11
	 */
	public static String loadJavaScript(Execution exec, String path) throws IOException {
		return loadFileContentAsString(exec, path);
	}

	/**
	 * Loads the content of a file as a String.
	 *
	 * @since 8.5.1
	 */
	public static String loadFileContentAsString(Execution exec, String path) throws IOException {
		path = exec.locate(path);
		//ZK-1345: use WebApps.getCurrent() instead of exec.getDesktop().getWebApp()
		InputStream is = WebApps.getCurrent().getResourceAsStream(path);
		if (is == null)
			throw new UiException("Unable to load " + path);
		final byte[] bs = Files.readAll(is);
		Files.close(is);
		return new String(bs, "UTF-8"); //UTF-8 is assumed
	}

	/** Device info.
	 */
	private static class DeviceInfo implements DeviceConfig {
		/** The class or class name of {@link Device}
		 * of the device's implementation.
		 */
		private Object _dvcls;
		private String _uamsg;
		/** The class name or class of {@link ServerPush}.
		 */
		private Object _spushcls;
		private String _embed;

		private DeviceInfo() {
		}

		private DeviceInfo(Object deviceClass) {
			_dvcls = deviceClass;
		}

		private DeviceInfo(Object deviceClass, String unavailable, Class spushcls) {
			_dvcls = deviceClass;
			_uamsg = unavailable;
			_spushcls = spushcls;
		}

		/** Returns whether this device is valid, i.e., defined with a device class.
		 */
		private boolean isValid() {
			return _dvcls != null;
		}

		/** Sets the device class.
		 */
		private String setDeviceClass(Object cls) {
			final Object old = _dvcls;
			_dvcls = cls;
			return old instanceof Class ? ((Class) old).getName() : (String) old;
		}

		public String getUnavailableMessage() {
			return _uamsg;
		}

		public String setUnavailableMessage(String msg) {
			final String old = _uamsg;
			_uamsg = msg != null && msg.length() > 0 ? msg : null;
			return old;
		}

		/**
		 * @param cls the class name or class of the server push.
		 */
		public String setServerPushClass(Object cls) {
			final Object old = _spushcls;
			_spushcls = cls;
			return old instanceof Class ? ((Class) old).getName() : (String) old;
		}

		public String getServerPushClassName() {
			return _spushcls instanceof Class ? ((Class) _spushcls).getName() : (String) _spushcls;
		}

		public Class getServerPushClass() {
			try {
				return _spushcls instanceof Class ? (Class) _spushcls
						: _spushcls != null ? Classes.forNameByThread((String) _spushcls) : null;
			} catch (ClassNotFoundException ex) {
				throw new UiException("Class not found: " + _spushcls);
			}
		}

		public void addEmbedded(String content) {
			if (content != null && content.length() > 0)
				_embed = _embed != null ? _embed + '\n' + content : content;
		}

		public String getEmbedded() {
			return _embed;
		}

		/** Creates a device based on this device info.
		 */
		private Device newDevice(String deviceType) {
			if (_dvcls == null) //possible
				throw new DeviceNotFoundException(deviceType, MZk.NOT_FOUND, deviceType);

			try {
				final Class cls;
				if (_dvcls instanceof Class) {
					cls = (Class) _dvcls;
				} else {
					cls = Classes.forNameByThread((String) _dvcls);
					if (!Device.class.isAssignableFrom(cls))
						throw new IllegalArgumentException(cls + " must implements " + Device.class);
				}

				final Device device = (Device) cls.newInstance();
				device.init(deviceType, this);
				return device;
			} catch (Exception ex) {
				throw UiException.Aide.wrap(ex, "Unable to create " + _dvcls);
			}
		}
	}
}
