/* Radiogroup.java

	Purpose:

	Description:

	History:
		Fri Jun 17 09:20:41     2005, Created by tomyeh

Copyright (C) 2005 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.zul;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.zkoss.lang.Classes;
import org.zkoss.lang.Exceptions;
import org.zkoss.lang.MutableInteger;
import org.zkoss.lang.Objects;
import org.zkoss.lang.Strings;
import org.zkoss.xel.VariableResolver;
import org.zkoss.zk.ui.Component;
import org.zkoss.zk.ui.Page;
import org.zkoss.zk.ui.UiException;
import org.zkoss.zk.ui.WrongValueException;
import org.zkoss.zk.ui.event.CheckEvent;
import org.zkoss.zk.ui.event.Event;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zk.ui.ext.Disable;
import org.zkoss.zk.ui.sys.ShadowElementsCtrl;
import org.zkoss.zk.ui.util.ComponentCloneListener;
import org.zkoss.zk.ui.util.ForEachStatus;
import org.zkoss.zk.ui.util.Template;
import org.zkoss.zul.event.ListDataEvent;
import org.zkoss.zul.event.ListDataListener;
import org.zkoss.zul.event.ZulEvents;
import org.zkoss.zul.ext.Selectable;
import org.zkoss.zul.impl.XulElement;

/**
 * A radio group.
 *
 * <p>Note: To support the versatile layout, a radio group accepts any kind of
 * children, including {@link Radio}. On the other hand, the parent of
 * a radio, if any, must be a radio group.
 *
 * @author tomyeh
 */
public class Radiogroup extends XulElement implements Disable {
	private static final Logger log = LoggerFactory.getLogger(Radiogroup.class);

	private static final String ZUL_RADIOGROUP_ON_INITRENDER = "zul.Radiogroup.ON_INITRENDER";
	private String _orient = "horizontal";
	/** The name of all child radio buttons. */
	private String _name;
	/** A list of external radio ({@link Radio}) components. */
	private List<Radio> _externs;
	private int _jsel = -1;
	private ListModel<?> _model;
	private RadioRenderer<?> _renderer;
	private transient ListDataListener _dataListener;
	private boolean _disabled;

	static {
		addClientEvent(Radiogroup.class, Events.ON_CHECK, CE_IMPORTANT | CE_REPEAT_IGNORE);
	}

	public Radiogroup() {
		_name = genGroupName();
	}

	/** Returns the orient.
	 * <p>Default: "horizontal".
	 */
	public String getOrient() {
		return _orient;
	}

	/** Sets the orient.
	 * @param orient either "horizontal" or "vertical".
	 */
	public void setOrient(String orient) throws WrongValueException {
		if (!"horizontal".equals(orient) && !"vertical".equals(orient))
			throw new WrongValueException("orient cannot be " + orient);

		if (!Objects.equals(_orient, orient)) {
			_orient = orient;
			smartUpdate("orient", _orient);
		}
	}

	/** Returns a readonly list of {@link Radio}.
	 * Note: any update to the list won't affect the state of this radio group.
	 * @since 5.0.4
	 */
	public List<Radio> getItems() {
		//FUTURE: the algorithm is stupid and it shall be similar to Listbox
		//however, it is OK since there won't be many radio buttons in a group
		final List<Radio> items = new ArrayList<Radio>();
		getItems0(this, items);
		if (_externs != null)
			for (Radio radio : _externs) {
				if (!isRedudant(radio))
					items.add(radio);
			}
		return items;
	}

	private static void getItems0(Component comp, List<Radio> items) {
		for (Component child : comp.getChildren()) {
			if (child instanceof Radio)
				items.add((Radio) child);
			else if (!(child instanceof Radiogroup)) //skip nested radiogroup
				getItems0(child, items);
		}
	}

	/** Returns the radio button at the specified index.
	 */
	public Radio getItemAtIndex(int index) {
		if (index < 0)
			throw new IndexOutOfBoundsException("Wrong index: " + index);

		final MutableInteger cur = new MutableInteger(0);
		Radio radio = getAt(this, cur, index);
		if (radio != null)
			return radio;
		if (_externs != null)
			for (Radio r : _externs) {
				if (!isRedudant(r) && cur.value++ == index)
					return r;
			}
		throw new IndexOutOfBoundsException(index + " out of 0.." + (cur.value - 1));
	}

	private static Radio getAt(Component comp, MutableInteger cur, int index) {
		for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
			final Component child = (Component) it.next();
			if (child instanceof Radio) {
				if (cur.value++ == index)
					return (Radio) child;
			} else if (!(child instanceof Radiogroup)) { //skip nested radiogroup
				Radio r = getAt(child, cur, index);
				if (r != null)
					return r;
			}
		}
		return null;
	}

	private boolean isRedudant(Radio radio) {
		for (Component p = radio; (p = p.getParent()) != null;)
			if (p instanceof Radiogroup)
				return p == this;
		return false;
	}

	/** Returns the number of radio buttons in this group.
	 */
	public int getItemCount() {
		int sum = countItems(this);
		if (_externs != null)
			for (Radio radio : _externs) {
				if (!isRedudant(radio))
					++sum;
			}
		return sum;
	}

	private static int countItems(Component comp) {
		int sum = 0;
		for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
			final Component child = (Component) it.next();
			if (child instanceof Radio)
				++sum;
			else if (!(child instanceof Radiogroup)) //skip nested radiogroup
				sum += countItems(child);
		}
		return sum;
	}

	/** Returns the index of the selected radio button (-1 if no one is selected).
	 *
	 * Note: The index of the external radio button could be unintuitive
	 * since radio group always count descendant radio before external radio.
	 */
	public int getSelectedIndex() {
		return _jsel;
	}

	/** Deselects all of the currently selected radio button and selects
	 * the radio button with the given index.
	 */
	public void setSelectedIndex(int jsel) {
		if (jsel < 0)
			jsel = -1;
		if (_jsel != jsel) {
			if (jsel < 0) {
				Radio r = getSelectedItem();
				if (r != null)
					r.setSelected(false);
			} else {
				getItemAtIndex(jsel).setSelected(true);
			}
		}
	}

	/** Returns the selected radio button.
	 */
	public Radio getSelectedItem() {
		return _jsel >= 0 ? getItemAtIndex(_jsel) : null;
	}

	/**  Deselects all of the currently selected radio buttons and selects
	 * the given radio button.
	 */
	public void setSelectedItem(Radio item) {
		if (item == null) {
			setSelectedIndex(-1);
		} else {
			if (item.getRadiogroup() != this)
				throw new UiException("Not a child: " + item);
			item.setSelected(true);
		}
	}

	/** Appends a radio button.
	 */
	public Radio appendItem(String label, String value) {
		final Radio item = new Radio();
		item.setLabel(label);
		item.setValue(value);
		item.setParent(this);
		return item;
	}

	/**  Removes the child radio button in the radio group at the given index.
	 * @return the removed radio button.
	 */
	public Radio removeItemAt(int index) {
		final Radio item = getItemAtIndex(index);
		if (item != null && !removeExternal(item)) {
			final Component p = item.getParent();
			if (p != null)
				p.removeChild(item);
		}
		return item;
	}

	/** Returns the name of this group of radio buttons.
	 * All child radio buttons shared the same name ({@link Radio#getName}).
	 * <p>Default: automatically generated a unique name
	 * <p>Don't use this method if your application is purely based
	 * on ZK's event-driven model.
	 */
	public String getName() {
		return _name;
	}

	/** Sets the name of this group of radio buttons.
	 * All child radio buttons shared the same name ({@link Radio#getName}).
	 * <p>Don't use this method if your application is purely based
	 * on ZK's event-driven model.
	 */
	public void setName(String name) {
		if (name != null && name.length() == 0)
			name = null;
		if (!Objects.equals(_name, name)) {
			_name = name;
			smartUpdate("name", _name);
		}
	}

	//utilities for radio//
	/** Called when a radio is added to this group.
	 */
	/*package*/ void fixOnAdd(Radio child, boolean external) {
		if (external && _jsel >= 0 && child.isSelected()) {
			child.setSelected(false); //it will call fixSelectedIndex
		} else {
			fixSelectedIndex();
		}
	}

	/** Called when a radio is removed from this group.
	 */
	/*package*/ void fixOnRemove(Radio child) {
		if (child.isSelected()) {
			_jsel = -1;
		} else if (_jsel > 0) { //excluding 0
			fixSelectedIndex();
		}
	}

	/** Fix the selected index, _jsel, assuming there are no selected one
	 * before (and excludes) j-the radio button.
	 */
	/*package*/ void fixSelectedIndex() {
		final MutableInteger cur = new MutableInteger(0);
		_jsel = fixSelIndex(this, cur);

		if (_jsel < 0 && _externs != null)
			for (Radio radio : _externs) {
				if (!isRedudant(radio)) {
					if (radio.isSelected()) {
						_jsel = cur.value;
						break; //found
					}
					++cur.value;
				}
			}
	}

	private static int fixSelIndex(Component comp, MutableInteger cur) {
		for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
			final Component child = (Component) it.next();
			if (child instanceof Radio) {
				if (((Radio) child).isSelected())
					return cur.value;
				++cur.value;
			} else if (!(child instanceof Radiogroup)) { //skip nested radiogroup
				int jsel = fixSelIndex(child, cur);
				if (jsel >= 0)
					return jsel;
			}
		}
		return -1;
	}

	/** Adds an external radio. An external radio is a radio that is NOT a
	 * descendant of the radio group.
	 */
	/*package*/ void addExternal(Radio radio) {
		if (_externs == null)
			_externs = new LinkedList<Radio>();
		_externs.add(radio);
		if (_disabled)
			radio.setDisabled(true);
		if (!isRedudant(radio))
			fixOnAdd(radio, true);
	}

	/** Removes an external radio.
	 */
	/*package*/ boolean removeExternal(Radio radio) {
		if (_externs != null && _externs.remove(radio)) {
			if (!isRedudant(radio))
				fixOnRemove(radio);
			return true;
		}
		return false;
	}

	/** Generates the group name for child radio buttons.
	 */
	private String genGroupName() {
		return Strings.encode(new StringBuffer(16).append("_pg"), System.identityHashCode(this)).toString();
	}

	/** Returns whether it is disabled.
	 * <p>Default: false.
	 * @since 9.6.0
	 */
	public boolean isDisabled() {
		return _disabled;
	}

	/** Sets whether the radiogroup is disabled.
	 * All the radios belong to this radiogroup will be set to disabled or not disabled as well.
	 * Notice: Once a radio is added to a disabled radiogroup (including external radio),
	 * the radio will be set to disabled too.
	 * @param disabled whether the radiogroup is disabled
	 * @since 9.6.0
	 */
	public void setDisabled(boolean disabled) {
		if (this._disabled != disabled) {
			this._disabled = disabled;
			List<Radio> items = getItems();
			for (Radio radio : items) {
				radio.setDisabled(disabled);
			}
		}
	}

	protected void renderProperties(org.zkoss.zk.ui.sys.ContentRenderer renderer) throws java.io.IOException {
		super.renderProperties(renderer);
		if (_name != null)
			render(renderer, "name", _name);
		if (!"horizontal".equals(_orient))
			render(renderer, "orient", _orient);
		if (isDisabled()) {
			render(renderer, "disabled", true);
		}
		if (_jsel >= 0) {
			render(renderer, "selectedIndex", _jsel);
		}
	}

	//-- ListModel dependent codes --//
	/** Returns the list model associated with this radiogroup, or null
	 * if this radiogroup is not associated with any list data model.
	 * @since 6.0.0
	 */
	@SuppressWarnings("unchecked")
	public <T> ListModel<T> getModel() {
		return (ListModel) _model;
	}

	/** Sets the list model associated with this radiogroup.
	 * If a non-null model is assigned, no matter whether it is the same as
	 * the previous, it will always cause re-render.
	 *
	 * @param model the list model to associate, or null to dis-associate
	 * any previous model.
	 * @exception UiException if failed to initialize with the model
	 * @since 6.0.0
	 */
	public void setModel(ListModel<?> model) {
		if (model != null) {
			if (!(model instanceof Selectable))
				throw new UiException(model.getClass() + " must implement " + Selectable.class);

			if (_model != model) {
				if (_model != null) {
					_model.removeListDataListener(_dataListener);
					//2012/1/11 TonyQ:Here we only clear children but not external radioss.
				} else if (!getChildren().isEmpty())
					getChildren().clear();
				_model = model;
				initDataListener();
			}

			postOnInitRender(null);
			//Since user might setModel and setRender separately or repeatedly,
			//we don't handle it right now until the event processing phase
			//such that we won't render the same set of data twice
			//--
			//For better performance, we shall load the first few row now
			//(to save a round trip)
		} else if (_model != null) {
			_model.removeListDataListener(_dataListener);
			_model = null;
			if (!getChildren().isEmpty())
				getChildren().clear();
		}
	}

	private void initDataListener() {
		if (_dataListener == null) {
			_dataListener = new ListDataListener() {
				public void onChange(ListDataEvent event) {
					// Bug B30-1906748.zul
					switch (event.getType()) {
					case ListDataEvent.SELECTION_CHANGED:
						doSelectionChanged();
						return; //nothing changed so need to rerender
					case ListDataEvent.MULTIPLE_CHANGED:
						return; //nothing to do
					}
					postOnInitRender(null);
				}
			};
		}

		_model.addListDataListener(_dataListener);
	}

	private void doSelectionChanged() {
		final Selectable<Object> smodel = getSelectableModel();
		if (smodel.isSelectionEmpty()) {
			if (_jsel >= 0)
				setSelectedItem(null);
			return;
		}

		if (_jsel >= 0 && smodel.isSelected(_model.getElementAt(_jsel)))
			return; //nothing changed

		int j = 0;
		for (final Radio item : getItems()) {
			if (smodel.isSelected(_model.getElementAt(j++))) {
				setSelectedItem(item);
				return;
			}
		}
		setSelectedItem(null); //something wrong but be self-protected
	}

	@SuppressWarnings("unchecked")
	private Selectable<Object> getSelectableModel() {
		return (Selectable<Object>) _model;
	}

	private void postOnInitRender(String idx) {
		//20080724, Henri Chen: optimize to avoid postOnInitRender twice
		if (getAttribute(ZUL_RADIOGROUP_ON_INITRENDER) == null) {
			//Bug #2010389
			setAttribute(ZUL_RADIOGROUP_ON_INITRENDER, Boolean.TRUE); //flag syncModel
			Events.postEvent("onInitRender", this, idx);
		}
	}

	/**
	 * For model rendering
	 * @param data
	 */
	@SuppressWarnings("rawtypes")
	public void onInitRender(Event data) {
		//Bug #2010389
		removeAttribute(ZUL_RADIOGROUP_ON_INITRENDER); //clear syncModel flag
		final Renderer renderer = new Renderer();
		final ListModel subset = _model;
		try {
			if (!getChildren().isEmpty())
				getChildren().clear();
			int pgsz = subset.getSize(), ofs = 0;
			for (int j = 0; j < pgsz; ++j) {
				Radio item = new Radio();
				item.applyProperties();
				item.setParent(this);
				final int index = j + ofs;
				final Object value = subset.getElementAt(index);
				renderer.render(item, value, index);
				Object v = item.getAttribute(Attributes.MODEL_RENDERAS);
				if (v != null) //a new item is created to replace the existent one
					item = (Radio) v;
				if (getSelectableModel().isSelected(value))
					setSelectedItem(item);
			}
		} catch (Throwable ex) {
			renderer.doCatch(ex);
		} finally {
			renderer.doFinally();
		}
		Events.postEvent("onInitRenderLater", this, null); // notify databinding load-when.
		Events.postEvent(ZulEvents.ON_AFTER_RENDER, this, null); // notify the combobox when items have been rendered.
	}

	/**
	 * We need to sync model selection to keep select status .
	 * @see Radio#service(org.zkoss.zk.au.AuRequest, boolean)
	 */
	/*package*/ void syncSelectionToModel() {
		if (_model != null) {
			List<Object> selObjs = new ArrayList<Object>();
			if (_jsel >= 0)
				selObjs.add(_model.getElementAt(_jsel));
			getSelectableModel().setSelection(selObjs);
		}
	}

	/** Returns the renderer used to render items.
	 */
	@SuppressWarnings("unchecked")
	private <T> RadioRenderer<T> getRealRenderer() {
		return _renderer != null ? (RadioRenderer<T>) _renderer : _defRend;
	}

	/** Returns the renderer to render each radio, or null if the default
	 * renderer is used.
	 * @since 6.0.0
	 */
	@SuppressWarnings("unchecked")
	public <T> RadioRenderer<T> getRadioRenderer() {
		return (RadioRenderer<T>) _renderer;
	}

	/** Sets the renderer which is used to render each row
	 * if {@link #getModel} is not null.
	 *
	 * <p>Note: changing a render will not cause the radiogroup to re-render.
	 * If you want it to re-render, you could assign the same model again
	 * (i.e., setModel(getModel())), or fire an {@link ListDataEvent} event.
	 *
	 * @param renderer the renderer, or null to use the default.
	 * @exception UiException if failed to initialize with the model
	 * @since 6.0.0
	 */
	public void setRadioRenderer(RadioRenderer<?> renderer) {
		_renderer = renderer;
	}

	/** Sets the renderer by use of a class name.
	 * It creates an instance automatically.
	 *@since 6.0.0
	 */
	public void setRadioRenderer(String clsnm) throws ClassNotFoundException, NoSuchMethodException,
			IllegalAccessException, InstantiationException, java.lang.reflect.InvocationTargetException {
		if (clsnm != null)
			setRadioRenderer((RadioRenderer) Classes.newInstanceByThread(clsnm));
	}

	/**
	 * The default Renderer for model rendering.
	 */
	@SuppressWarnings("rawtypes")
	private static final RadioRenderer _defRend = new RadioRenderer() {
		public void render(final Radio item, final Object data, final int index) throws Exception {
			final Radiogroup cb = (Radiogroup) item.getParent();
			final Template tm = cb.getTemplate("model");
			if (tm == null) {
				item.setLabel(Objects.toString(data));
				item.setValue(data);
			} else {
				final Component[] items = ShadowElementsCtrl
						.filterOutShadows(tm.create(item.getParent(), item, new VariableResolver() {
					public Object resolveVariable(String name) {
						if ("each".equals(name)) {
							return data;
						} else if ("forEachStatus".equals(name)) {
							return new ForEachStatus() {
								public ForEachStatus getPrevious() {
									return null;
								}

								public Object getEach() {
									return getCurrent();
								}

								public int getIndex() {
									return index;
								}

								public Integer getBegin() {
									return 0;
								}

								public Integer getEnd() {
									return cb.getModel().getSize();
								}

								public Object getCurrent() {
									return data;
								}

								public boolean isFirst() {
									return getCount() == 1;
								}

								public boolean isLast() {
									return getIndex() + 1 == getEnd();
								}

								public Integer getStep() {
									return null;
								}

								public int getCount() {
									return getIndex() + 1;
								}
							};
						} else {
							return null;
						}
					}
				}, null));
				if (items.length != 1)
					throw new UiException("The model template must have exactly one item, not " + items.length);

				final Radio nci = (Radio) items[0];
				if (nci.getValue() == null) //template might set it
					nci.setValue(data);
				item.setAttribute(Attributes.MODEL_RENDERAS, nci);
				//indicate a new item is created to replace the existent one
				item.detach();
			}

		}
	};

	/** Used to render Radio if _model is specified. */
	private class Renderer implements java.io.Serializable {
		@SuppressWarnings("rawtypes")
		private final RadioRenderer _renderer;
		private boolean _rendered, _ctrled;

		private Renderer() {
			_renderer = getRealRenderer();
		}

		@SuppressWarnings("unchecked")
		private void render(Radio item, Object value, int index) throws Throwable {
			if (!_rendered && (_renderer instanceof RendererCtrl)) {
				((RendererCtrl) _renderer).doTry();
				_ctrled = true;
			}

			try {
				_renderer.render(item, value, index);
			} catch (Throwable ex) {
				try {
					item.setLabel(Exceptions.getMessage(ex));
				} catch (Throwable t) {
					log.error("", t);
				}
				throw ex;
			}
			_rendered = true;
		}

		private void doCatch(Throwable ex) {
			if (_ctrled) {
				try {
					((RendererCtrl) _renderer).doCatch(ex);
				} catch (Throwable t) {
					throw UiException.Aide.wrap(t);
				}
			} else {
				throw UiException.Aide.wrap(ex);
			}
		}

		private void doFinally() {
			if (_ctrled)
				((RendererCtrl) _renderer).doFinally();
		}
	}

	//Cloneable//
	public Object clone() {
		final Radiogroup clone = (Radiogroup) super.clone();
		fixClone(clone);
		if (clone._model != null) {
			if (clone._model instanceof ComponentCloneListener) {
				final ListModel model = (ListModel) ((ComponentCloneListener) clone._model).willClone(clone);
				if (model != null)
					clone._model = model;
			}
			clone._dataListener = null;
			clone.initDataListener();
		}
		return clone;
	}

	private static void fixClone(Radiogroup clone) {
		if (clone._name.startsWith("_pg"))
			clone._name = clone.genGroupName();
	}

	//	Serializable//
	private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
		s.defaultReadObject();

		if (_model != null) {
			initDataListener();
		}
	}
	//-- ComponentCtrl --//
	/** Processes an AU request.
	 * @since 10.0.0
	 */
	public void service(org.zkoss.zk.au.AuRequest request, boolean everError) {
		final String cmd = request.getCommand();
		if (cmd.equals(Events.ON_CHECK)) {
			// for compatibility (before 10.0.0), the target of check event in radio group should be the checked radio
			CheckEvent evt = new CheckEvent(request.getCommand(), getSelectedItem(), (Boolean) request.getData().get(""));
			Events.postEvent(this, evt);
		} else
			super.service(request, everError);
	}

	public void onPageAttached(Page newpage, Page oldpage) {
		super.onPageAttached(newpage, oldpage);
		if (_model != null) {
			postOnInitRender(null);
			if (_dataListener != null) {
				_model.removeListDataListener(_dataListener);
				_model.addListDataListener(_dataListener);
			}
		}
	}

	public void onPageDetached(Page page) {
		super.onPageDetached(page);
		if (_model != null && _dataListener != null)
			_model.removeListDataListener(_dataListener);
	}

	@Override
	public void onChildAdded(Component child) {
		super.onChildAdded(child);
		if (_disabled) {
			if (child instanceof Radio) {
				((Radio) child).setDisabled(true);
			} else {
				// ZK-4810: find all nested radio
				List<Radio> items = new ArrayList<Radio>();
				getItems0(child, items);
				for (Radio radio : items) {
					radio.setDisabled(true);
				}
			}
		}
	}
}
