package com.michaelbaranov.microba.calendar;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyVetoException;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

import javax.swing.JFormattedTextField;
import javax.swing.event.EventListenerList;

import com.michaelbaranov.microba.calendar.ui.CalendarPaneUI;
import com.michaelbaranov.microba.common.CommitEvent;
import com.michaelbaranov.microba.common.CommitListener;
import com.michaelbaranov.microba.common.MicrobaComponent;

/**
 * A concrete implementation of JComponent. Capable of displaying and selecting
 * dates, much like a real-world calendar.
 * 
 * @author Michael Baranov
 */
public class CalendarPane extends MicrobaComponent implements
		CalendarColors {

	/**
	 * The name of a "date" property.
	 */
	public static final String PROPERTY_NAME_DATE = "date";

	/**
	 * The name of a "locale" property.
	 */
	public static final String PROPERTY_NAME_LOCALE = "locale";

	/**
	 * The name of a "zone" property.
	 */
	public static final String PROPERTY_NAME_ZONE = "zone";

	/**
	 * The name of a "style" property.
	 */
	public static final String PROPERTY_NAME_STYLE = "style";

	/**
	 * The name of a "showTodayButton" property.
	 */
	public static final String PROPERTY_NAME_SHOW_TODAY_BTN = "showTodayButton";

	/**
	 * The name of a "showNoneButton" property.
	 */
	public static final String PROPERTY_NAME_SHOW_NONE_BTN = "showNoneButton";

	/**
	 * The name of a "focusLocatBehavior" property.
	 */
	public static final String PROPERTY_NAME_FOCUS_LOST_BEHAVIOR = "focusLostBehavior";

	/**
	 * The name of a "vetoPolicy" property.
	 */
	public static final String PROPERTY_NAME_VETO_POLICY = "vetoPlicy";

	/**
	 * The name of a "holidayPolicy" property.
	 */
	public static final String PROPERTY_NAME_HOLIDAY_POLICY = "holidayPolicy";

	/**
	 * The name of a "resources" property.
	 */
	public static final String PROPERTY_NAME_RESOURCES = "resources";

	/**
	 * The name of a "resources" property.
	 */
	public static final String PROPERTY_NAME_SHOW_NUMBER_WEEK = "showNumberOfWeek";

	/**
	 * The name of a "stripTime" property.
	 */
	public static final String PROPERTY_NAME_STRIP_TIME = "stripTime";

	/**
	 * A constant for the "style" property.
	 */
	public static final int STYLE_MODERN = 0x10;

	/**
	 * A constant for the "style" property.
	 */
	public static final int STYLE_CLASSIC = 0x20;

	private static final String uiClassID = "microba.CalendarPaneUI";

	private EventListenerList commitListenerList = new EventListenerList();

	private EventListenerList actionListenerList = new EventListenerList();

	private Date date;

	private TimeZone zone;

	private Locale locale;

	private VetoPolicy vetoPolicy;

	private HolidayPolicy holidayPolicy;

	private CalendarResources resources;

	private int style;

	private boolean showTodayButton;

	private boolean showNoneButton;

	private int focusLostBehavior;

	private boolean showNumberOfWeek;

	private boolean stripTime;

	public String getUIClassID() {
		return uiClassID;
	}

	/**
	 * Constructor.
	 */
	public CalendarPane() {
		this(null, 0, Locale.getDefault(), TimeZone.getDefault());
	}

	/**
	 * Constructor.
	 */
	public CalendarPane(int style) {
		this(null, style, Locale.getDefault(), TimeZone.getDefault());
	}

	/**
	 * Constructor.
	 */
	public CalendarPane(Date initialDate) {
		this(initialDate, 0, Locale.getDefault(), TimeZone.getDefault());
	}

	/**
	 * Constructor.
	 */
	public CalendarPane(Date initialDate, int style) {
		this(initialDate, style, Locale.getDefault(), TimeZone.getDefault());
	}

	/**
	 * Constructor.
	 */
	public CalendarPane(Date initialDate, int style, Locale locale) {
		this(initialDate, style, locale, TimeZone.getDefault());
	}

	/**
	 * Constructor.
	 */
	public CalendarPane(Date initialDate, int style, Locale locale,
			TimeZone zone) {
		checkStyle(style);
		checkLocale(locale);
		checkTimeZone(zone);
		this.style = style;
		this.date = initialDate;
		this.locale = locale;
		this.zone = zone;
		this.focusLostBehavior = JFormattedTextField.COMMIT_OR_REVERT;
		this.showTodayButton = true;
		this.showNoneButton = true;
		this.vetoPolicy = null;
		this.resources = new DefaultCalendarResources();
		this.stripTime = true;

		// forward date property change to action event
		addPropertyChangeListener(PROPERTY_NAME_DATE,
				new PropertyChangeListener() {
					public void propertyChange(PropertyChangeEvent evt) {
						fireActionEvent();
					}
				});

		updateUI();
	}

	/**
	 * Returns currently selected date in the control.
	 * <p>
	 * The returned date is guaranteed to pass the restriction check by the
	 * current {@link VetoPolicy}. Based on the value of {@link #stripTime}
	 * property, the returned date may be automatically stripped.
	 * 
	 * @return currently selected date
	 * @see #stripTime
	 * @see #stripTime(Date, TimeZone, Locale)
	 */
	public Date getDate() {
		if (this.stripTime)
			return stripTime(date, getZone(), getLocale());
		else
			return date;
	}

	/**
	 * Sets currently selected date to the control.
	 * <p>
	 * The given date is checked against the current {@link VetoPolicy}. If the
	 * check is passed, the date is transferred to the control and the control
	 * is updated to display the date.
	 * <p>
	 * A {@link PropertyChangeEvent} may be fired, an {@link ActionEvent} may be
	 * fired.
	 * 
	 * @param date
	 *            the date to set
	 * @throws PropertyVetoException
	 *             if the date is restricted by the current {@link VetoPolicy}.
	 * @see #getVetoPolicy()
	 * @see #setVetoPolicy(VetoPolicy)
	 * @see #addActionListener(ActionListener)
	 */
	public void setDate(Date date) throws PropertyVetoException {
		if (!checkDate(date)) {
			PropertyChangeEvent propertyChangeEvent = new PropertyChangeEvent(
					this, PROPERTY_NAME_DATE, this.date, date);
			throw new PropertyVetoException(
					"Value vetoed by current vetoPolicy", propertyChangeEvent);
		}
		Date old = this.date;
		this.date = date;
		if (old != null || date != null) {
			firePropertyChange(PROPERTY_NAME_DATE, old, date);
		}
	}

	/**
	 * Returns current locale.
	 * 
	 * @return current locale
	 */
	public Locale getLocale() {
		return locale;
	}

	/**
	 * Sets current locale.
	 * <p>
	 * The locale is used to construct internal {@link Calendar} instances and
	 * affects visual representation of the control.
	 * 
	 * @param locale
	 *            the locale to set
	 */
	public void setLocale(Locale locale) {
		Locale old = getLocale();
		this.locale = locale;
		firePropertyChange(PROPERTY_NAME_LOCALE, old, getLocale());
	}

	/**
	 * Returns current time zone.
	 * 
	 * @return current time zone
	 */
	public TimeZone getZone() {
		return zone;
	}

	/**
	 * Sets current time zone.
	 * <p>
	 * The time zone is used to construct internal {@link Calendar} instances
	 * and affects visual representation of the control. The dates returned by
	 * {@link #getDate() } will have all time components set to zero considering
	 * the current locale.
	 * 
	 * @param zone
	 *            the time zone to set
	 */
	public void setZone(TimeZone zone) {
		TimeZone old = getZone();
		this.zone = zone;
		firePropertyChange(PROPERTY_NAME_ZONE, old, getZone());
	}

	/**
	 * Returns current visual style of the control.
	 * 
	 * @return current visual style constant.
	 */
	public int getStyle() {
		return style;
	}

	/**
	 * Sets the current visual style of the control.
	 * <p>
	 * The control is then updated to reflect the new style.
	 * 
	 * @param style
	 *            the style to set
	 * @see #STYLE_CLASSIC
	 * @see #STYLE_MODERN
	 */
	public void setStyle(int style) {
		style = checkStyle(style);
		int old = this.style;
		this.style = style;
		firePropertyChange(PROPERTY_NAME_STYLE, old, style);
	}

	/**
	 * Is today button visible?
	 * <p>
	 * The today button allows the user to quickly select current date.
	 * 
	 * @return <code>true</code> if the today button is visible,
	 *         <code>false</code> otherwise
	 */
	public boolean isShowTodayButton() {
		return showTodayButton;
	}

	/**
	 * Shows or hides the today-button.
	 * <p>
	 * The today-button allows the user to quickly select current date.
	 * 
	 * @param visible
	 *            <code>true</code> to show the today-button
	 *            <code>false</code> to hide
	 */
	public void setShowTodayButton(boolean visible) {
		Boolean old = new Boolean(this.showTodayButton);
		this.showTodayButton = visible;
		firePropertyChange(PROPERTY_NAME_SHOW_TODAY_BTN, old, new Boolean(
				visible));
	}

	/**
	 * Is the none-button visible?
	 * <p>
	 * The none-button allows the user to select empty date (null-date, no
	 * date).
	 * 
	 * @return <code>true</code> if the none-button is visible,
	 *         <code>false</code> otherwise
	 */
	public boolean isShowNoneButton() {
		return showNoneButton;
	}

	/**
	 * Shows or hides the none-button.
	 * <p>
	 * The none-button allows the user to select empty date (null-date, no
	 * date).
	 * 
	 * @param visible
	 *            <code>true</code> to show the none-button <code>false</code>
	 *            to hide
	 */
	public void setShowNoneButton(boolean visible) {
		Boolean old = new Boolean(this.showNoneButton);
		this.showNoneButton = visible;
		firePropertyChange(PROPERTY_NAME_SHOW_NONE_BTN, old, new Boolean(
				visible));
	}

	/**
	 * Returns the focus lost behavior. Possible values are:
	 * 
	 * <ul>
	 * <li><code> {@link JFormattedTextField#COMMIT}</code>
	 * <li><code> {@link JFormattedTextField#COMMIT_OR_REVERT}</code>
	 * <li><code> {@link JFormattedTextField#REVERT}</code>
	 * <li><code> {@link JFormattedTextField#PERSIST}</code>
	 * </ul>
	 * Original meaning preserved.
	 * 
	 * @return the focus lost behavior constant
	 * @see JFormattedTextField
	 */
	public int getFocusLostBehavior() {
		return focusLostBehavior;
	}

	/**
	 * Sets the focus lost behaviour. Possible values are:
	 * 
	 * <ul>
	 * <li><code> {@link JFormattedTextField#COMMIT}</code>
	 * <li><code> {@link JFormattedTextField#COMMIT_OR_REVERT}</code>
	 * <li><code> {@link JFormattedTextField#REVERT}</code>
	 * <li><code> {@link JFormattedTextField#PERSIST}</code>
	 * </ul>
	 * Original meaning preserved.
	 * 
	 * @param behavior
	 *            the focus lost behavior constant
	 * @see JFormattedTextField
	 */
	public void setFocusLostBehavior(int behavior) {
		behavior = checkFocusLostbehavior(behavior);
		int old = this.focusLostBehavior;
		this.focusLostBehavior = behavior;
		firePropertyChange(PROPERTY_NAME_FOCUS_LOST_BEHAVIOR, old, behavior);
	}

	/**
	 * Resurns current calendar resources model.
	 * <p>
	 * The model is used to query localized resources for the control.
	 * 
	 * @return current calendar resources model
	 * @see CalendarResources
	 */
	public CalendarResources getResources() {
		return resources;
	}

	/**
	 * Sets current calendar resources model.
	 * <p>
	 * The model is used to query localized resources for the control.
	 * 
	 * @param resources
	 *            a calendar resources model to set. Should not be
	 *            <code>null</code>
	 * @see CalendarResources
	 */
	public void setResources(CalendarResources resources) {
		CalendarResources old = this.resources;
		this.resources = resources;
		firePropertyChange(PROPERTY_NAME_RESOURCES, old, resources);
	}

	/**
	 * Returns current holliday policy (model).
	 * <p>
	 * The policy is used to query holliday dates and holliday descriptions.
	 * 
	 * @return current holliday policy or <code>null</code> if none set
	 * @see HolidayPolicy
	 */
	public HolidayPolicy getHolidayPolicy() {
		return holidayPolicy;
	}

	/**
	 * Sets current holliday policy (model) then updates the control to reflect
	 * the policy set.
	 * <p>
	 * The policy is used to query holliday dates and holiday descriptions.
	 * 
	 * @param holidayPolicy
	 *            a holliday policy to set. May be <code>null</code>
	 * @see VetoPolicy
	 */
	public void setHolidayPolicy(HolidayPolicy holidayPolicy) {
		HolidayPolicy old = this.holidayPolicy;
		this.holidayPolicy = holidayPolicy;
		firePropertyChange(PROPERTY_NAME_HOLIDAY_POLICY, old, holidayPolicy);
	}

	/**
	 * Returns the current veto policy (model).
	 * <p>
	 * The policy is used to veto dates in the control.
	 * 
	 * @return current veto policy or <code>null</code> if none set
	 * @see VetoPolicy
	 */
	public VetoPolicy getVetoPolicy() {
		return vetoPolicy;
	}

	/**
	 * Sets the current veto policy (model).
	 * <p>
	 * The policy is used to veto dates in the control.
	 * 
	 * @param vetoModel
	 *            a veto policy to set. May be <code>null</code>
	 */
	public void setVetoPolicy(VetoPolicy vetoModel) {
		VetoPolicy old = this.vetoPolicy;
		this.vetoPolicy = vetoModel;
		firePropertyChange(PROPERTY_NAME_VETO_POLICY, old, vetoModel);
	}

	/**
	 * Is the number of every week visible?
	 * 
	 * @return <code>true</code> if the number of every week is visible,
	 *         <code>false</code> otherwise
	 */
	public boolean isShowNumberOfWeek() {
		return showNumberOfWeek;
	}

	/**
	 * Is time protion of the date automatically striped, based on current
	 * locale and ime zone?
	 * 
	 * @return <code>true</code> if {@link #getDate()} returns a stripped
	 *         date, <code>false</code> otherwise
	 * @see #setStripTime(boolean)
	 * @see #stripTime(Date, TimeZone, Locale)
	 */
	public boolean isStripTime() {
		return stripTime;
	}

	/**
	 * Makes {@link #getDate()} either strip the time portion of the date, or
	 * keep it.
	 * 
	 * @param stripTime
	 *            <code>true</code> to strip time, <code>false</code> to
	 *            keep time
	 */
	public void setStripTime(boolean stripTime) {
		this.stripTime = stripTime;
	}

	/**
	 * Shows or hides the the number of every week.
	 * <p>
	 * The number of week is based on the current locale for the component.
	 * 
	 * @param visible
	 *            <code>true</code> to show the the number of every week
	 *            <code>false</code> to hide
	 */
	public void setShowNumberOfWeek(boolean visible) {
		boolean old = this.showNumberOfWeek;
		this.showNumberOfWeek = visible;
		firePropertyChange(PROPERTY_NAME_SHOW_NUMBER_WEEK, old, visible);
	}

	/**
	 * Adds an {@link ActionListener} listener.
	 * 
	 * @param listener
	 *            a listener to add
	 * @see ActionListener
	 */
	public void addActionListener(ActionListener listener) {
		actionListenerList.add(ActionListener.class, listener);
	}

	/**
	 * Removes an {@link ActionListener} listener.
	 * 
	 * @param listener
	 *            a listener to remove
	 * @see ActionListener
	 */
	public void removeActionListener(ActionListener listener) {
		actionListenerList.remove(ActionListener.class, listener);

	}

	/**
	 * Adds an {@link CommitListener} listener.
	 * 
	 * @param listener
	 *            a listener to add
	 * @see CommitListener
	 */
	public void addCommitListener(CommitListener listener) {
		commitListenerList.add(CommitListener.class, listener);
	}

	/**
	 * Removes an {@link CommitListener} listener.
	 * 
	 * @param listener
	 *            a listener to remove
	 * @see CommitListener
	 */
	public void removeCommitListener(CommitListener listener) {
		commitListenerList.remove(CommitListener.class, listener);
	}

	/**
	 * Forces the control to commit current user's edit. The opertaion may fail
	 * because the date in the control may be restricted by current veto policy.
	 * If successfull, the current date of the control may change, a
	 * {@link CommitEvent} is fired.
	 * 
	 * @return <code>true</code> if successful, <code>false</code> otherwise
	 * @see #revertEdit()
	 * @see #getFocusLostBehavior()
	 * @see #setFocusLostBehavior(int)
	 */
	public boolean commitEdit() {
		try {
			((CalendarPaneUI) getUI()).commit();
			fireCommitEvent(true);
			return true;
		} catch (Exception e) {
			return false;
		}
	}

	/**
	 * Forces the control to revert current user's edit to reflect current
	 * control's date. The current date of the control may change, a
	 * {@link CommitEvent} is fired.
	 * 
	 * @see #revertEdit()
	 * @see #getFocusLostBehavior()
	 * @see #setFocusLostBehavior(int)
	 */
	public void revertEdit() {
		((CalendarPaneUI) getUI()).revert();
		fireCommitEvent(false);
	}

	/**
	 * Forces the control to commit or revert user's edit depending on the
	 * current focus lost behavior as if the focus would be lost.
	 * 
	 * @see #commitEdit()
	 * @see #revertEdit()
	 * @see #getFocusLostBehavior()
	 * @see #setFocusLostBehavior(int)
	 */
	public void commitOrRevert() {
		switch (focusLostBehavior) {
		case JFormattedTextField.REVERT:
			revertEdit();
			break;
		case JFormattedTextField.COMMIT:
			commitEdit();
			break;
		case JFormattedTextField.COMMIT_OR_REVERT:
			if (!commitEdit())
				revertEdit();
			break;
		case JFormattedTextField.PERSIST:
			// do nothing
			break;
		}
	}

	/**
	 * Fires a {@link CommitEvent} to all registered listeners.
	 * 
	 * @param commit
	 *            <code>true</code> to indicate commit, <code>false</code>
	 *            to indicate revert
	 * @see CommitEvent
	 * @see CommitListener
	 */
	public void fireCommitEvent(boolean commit) {
		Object[] listeners = commitListenerList.getListenerList();

		for (int i = listeners.length - 2; i >= 0; i -= 2)
			if (listeners[i] == CommitListener.class)
				((CommitListener) listeners[i + 1]).commit(new CommitEvent(
						this, commit));
	}

	/**
	 * Fires a {@link ActionEvent} to all registered listeners.
	 * 
	 * @see ActionEvent
	 * @see ActionListener
	 */
	public void fireActionEvent() {
		Object[] listeners = actionListenerList.getListenerList();

		for (int i = listeners.length - 2; i >= 0; i -= 2)
			if (listeners[i] == ActionListener.class)
				((ActionListener) listeners[i + 1])
						.actionPerformed(new ActionEvent(this, 0, "value"));
	}

	private void checkTimeZone(TimeZone zone) {
		if (zone == null)
			throw new IllegalArgumentException("'zone' can not be null.");

	}

	private void checkLocale(Locale locale) {
		if (locale == null)
			throw new IllegalArgumentException("'locale' can not be null.");

	}

	private int checkFocusLostbehavior(int behavior) {
		if (behavior != JFormattedTextField.COMMIT
				&& behavior != JFormattedTextField.COMMIT_OR_REVERT
				&& behavior != JFormattedTextField.REVERT
				&& behavior != JFormattedTextField.PERSIST)
			throw new IllegalArgumentException(
					PROPERTY_NAME_FOCUS_LOST_BEHAVIOR
							+ ": unrecognized behavior");
		return behavior;
	}

	private boolean checkDate(Date date) {
		if (vetoPolicy != null) {
			if (date == null)
				return !vetoPolicy.isRestrictNull(this);
			return !vetoPolicy.isRestricted(this, makeCurrentCalendar(date));
		} else
			return true;
	}

	private int checkStyle(int style) {
		if (style == 0)
			style = STYLE_CLASSIC;
		if (style != STYLE_CLASSIC && style != STYLE_MODERN)
			throw new IllegalArgumentException(PROPERTY_NAME_STYLE
					+ ": unrecognized style");
		return style;
	}

	private Calendar makeCurrentCalendar(Date date) {
		Calendar c = Calendar.getInstance(zone, locale);
		c.setTime(date);
		return c;
	}

	/**
	 * Returns same date as given, but time portion (hours, minutes, seconds,
	 * fraction of second) set to zero, based on given locale and time zone.
	 * Utility method.
	 * <p>
	 * Examle:<br>
	 * Fri Sep 29 15:57:23 EEST 2006 -> Fri Sep 29 00:00:00 EEST 2006
	 * 
	 * @param date
	 *            date to strip time from
	 * @param zone
	 *            time zone to get zero fields in
	 * @param locale
	 *            locale to base the calendar on
	 * @return stripped date
	 */
	public static Date stripTime(Date date, TimeZone zone, Locale locale) {
		if (date == null)
			return null;
		Calendar tmpCalendar = Calendar.getInstance(zone, locale);
		tmpCalendar.setTime(date);
		tmpCalendar.set(Calendar.HOUR_OF_DAY, tmpCalendar
				.getMinimum(Calendar.HOUR_OF_DAY));
		tmpCalendar.set(Calendar.MINUTE, tmpCalendar
				.getMinimum(Calendar.MINUTE));
		tmpCalendar.set(Calendar.SECOND, tmpCalendar
				.getMinimum(Calendar.SECOND));
		tmpCalendar.set(Calendar.MILLISECOND, tmpCalendar
				.getMinimum(Calendar.MILLISECOND));
		return tmpCalendar.getTime();
	}

}