package org.biomoby.shared.data;

import org.biomoby.registry.meta.Registry;
import org.biomoby.shared.MobyDataType;
import org.biomoby.shared.parser.MobyTags;

import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * A class representing a MOBY DateTime, which is a primitive in MOBY. 
 * This is a useful utility
 * class that parses and outputs dates and times in proper ISO 8601 format 
 * as required by the MOBY object description.  
 *
 * For a description of ISO 8601 as used on the Web, see 
 * http://www.w3.org/TR/NOTE-datetime
 *
 * Because getObject() will return a mutable GregorianCalendar, you can use
 * its methods to modify the underlying date of this MOBY object.  Since it
 * is a Gregorian calendar, if you
 * are representing dates before September 14th, 1752, I guarantee nothing.
 *
 * @author Paul Gordon
 */

public class MobyDataDateTime extends MobyDataObject{

    private GregorianCalendar value;

    /**
     * Construct the object using a DOM fragment.
     *
     * @throws IllegalArgumentException if the element is not a DateTime tag, or the text children of the element do not encode a valid ISO8601 date/time
     */
    public MobyDataDateTime(org.w3c.dom.Element element) throws IllegalArgumentException{
	this(element, null);
    }

    public MobyDataDateTime(org.w3c.dom.Element element, Registry registry) throws IllegalArgumentException{
	this(getName(element), getTextContents(element), registry);
	setId(getId(element));
	addNamespace(getNamespace(element, registry));
    }
    
    /**
     * Constructor to build a MOBY DateTime object using the W3C profile of an ISO 8601 formatted input string.
     * @param stringISO8601 if null, the current local date and time is used
     */
    public MobyDataDateTime(String articleName, String stringISO8601) throws IllegalArgumentException{
	this(articleName, stringISO8601, null);
    }

    public MobyDataDateTime(String articleName, String stringISO8601, Registry registry) throws IllegalArgumentException{
	super(articleName, registry);
	setDataType(MobyDataType.getDataType(MobyTags.MOBYDATETIME, registry));
	value = parseISO8601(stringISO8601.trim());
    }

    public MobyDataDateTime(String stringISO8601){
	this("", stringISO8601);
    }

    public MobyDataDateTime(String articleName, GregorianCalendar cal){
	this(articleName, cal, (Registry) null);
    }

    public MobyDataDateTime(String articleName, GregorianCalendar cal, Registry registry){
	super(articleName, "", registry);
	setDataType(MobyDataType.getDataType(MobyTags.MOBYDATETIME, registry));
	value = cal;
    }

    public MobyDataDateTime(GregorianCalendar cal){
	this("", cal);
    }

    /**
     * For a description of ISO 8601 as used on the Web, see http://www.w3.org/TR/NOTE-datetime
     * This method is based on org.w3.util.DateParser v. 1.4 by Beno&icirc;t Mah&eacute; (bmahe@w3.org)
     *
     * @param dateTime if null, the current local date and time is used, otherwise a valid ISO 8601 string
     */
    public static GregorianCalendar parseISO8601(String dateTime) throws IllegalArgumentException{
	// null = request for current date and time
	if(dateTime == null){
	    return new GregorianCalendar();
	}

	// YYYY-MM-DDThh:mm:ss.sTZD
	StringTokenizer st = new StringTokenizer(dateTime, "-T:.+Z", true);

	GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
	calendar.clear();
	try {
	    // Year
	    if (st.hasMoreTokens()) {
		int year = Integer.parseInt(st.nextToken());
		calendar.set(Calendar.YEAR, year);
	    } else {
		return calendar;
	    }
	    // Month
	    if (check(st, "-") && (st.hasMoreTokens())) {
		int month = Integer.parseInt(st.nextToken()) -1;
		calendar.set(Calendar.MONTH, month);
	    } else {
		return calendar;
	    }
	    // Day
	    if (check(st, "-") && (st.hasMoreTokens())) {
		int day = Integer.parseInt(st.nextToken());
		calendar.set(Calendar.DAY_OF_MONTH, day);
	    } else {
		return calendar;
	    }
	    // Hour
	    if (check(st, "T") && (st.hasMoreTokens())) {
		int hour = Integer.parseInt(st.nextToken());
		calendar.set(Calendar.HOUR_OF_DAY, hour);
	    } else {
		calendar.set(Calendar.HOUR_OF_DAY, 0);
		calendar.set(Calendar.MINUTE, 0);
		calendar.set(Calendar.SECOND, 0);
		calendar.set(Calendar.MILLISECOND, 0);
		return calendar;
	    }
	    // Minutes
	    if (check(st, ":") && (st.hasMoreTokens())) {
		int minutes = Integer.parseInt(st.nextToken());
		calendar.set(Calendar.MINUTE, minutes);
	    } else {
		calendar.set(Calendar.MINUTE, 0);
		calendar.set(Calendar.SECOND, 0);
		calendar.set(Calendar.MILLISECOND, 0);
		return calendar;
	    }

	    // Seconds available?
	    if (! st.hasMoreTokens()) {
		return calendar;
	    }
	    String tok = st.nextToken();
	    if (tok.equals(":")) { // seconds
		if (st.hasMoreTokens()) {
		    int secondes = Integer.parseInt(st.nextToken());
		    calendar.set(Calendar.SECOND, secondes);
		    if (! st.hasMoreTokens()) {
			return calendar;
		    }
		    // fractions of a sec
		    tok = st.nextToken();
		    if (tok.equals(".")) {
			// bug fixed, thx to Martin Bottcher
			String nt = st.nextToken();
			while(nt.length() < 3) {
			    nt += "0";
			}
			nt = nt.substring( 0, 3 ); //Cut trailing chars..
			int millisec = Integer.parseInt(nt);
			calendar.set(Calendar.MILLISECOND, millisec);
			if (! st.hasMoreTokens()) {
			    return calendar;
			}
			tok = st.nextToken();
		    } else {
			calendar.set(Calendar.MILLISECOND, 0);
		    }
		} else {
		    throw new IllegalArgumentException("No seconds specified");
		}
	    } else {
		calendar.set(Calendar.SECOND, 0);
		calendar.set(Calendar.MILLISECOND, 0);
	    }
	    // Timezone
	    if (! tok.equals("Z")) { // UTC
		if (! (tok.equals("+") || tok.equals("-"))) {
		    throw new IllegalArgumentException("only Z, + or - allowed");
		}
		boolean plus = tok.equals("+");
		if (! st.hasMoreTokens()) {
		    throw new IllegalArgumentException("Missing hour field in timezone offset");
		}
		String tzhour = st.nextToken();
		String tzmin  = "00";
		if (check(st, ":") && (st.hasMoreTokens())) {
		    tzmin = st.nextToken();
		} else {
		    throw new IllegalArgumentException("Missing minute field in timezone offset");
		}
		if (plus) {
		    calendar.setTimeZone(TimeZone.getTimeZone("GMT+"+tzhour+":"+tzmin));
		    calendar.add(Calendar.HOUR, Integer.parseInt(tzhour));
		    calendar.add(Calendar.MINUTE, Integer.parseInt(tzmin));
		    calendar.set(Calendar.DST_OFFSET, 0);  //ISO8601 does not deal with DST
		} else {
		    calendar.setTimeZone(TimeZone.getTimeZone("GMT-"+tzhour+":"+tzmin));
		    calendar.add(Calendar.HOUR, Integer.parseInt("-"+tzhour));
		    calendar.add(Calendar.MINUTE, Integer.parseInt("-"+tzmin));
		    calendar.set(Calendar.DST_OFFSET, 0);
		}
	    }
	} catch (NumberFormatException ex) {
	    throw new IllegalArgumentException("["+ex.getMessage()+ "] is not an integer");
	}
	return calendar;
    }

    private static boolean check(StringTokenizer st, String token) throws IllegalArgumentException{
	try {
	    if (st.nextToken().equals(token)) {
		return true;
	    } else {
		throw new IllegalArgumentException("Missing ["+token+"]");
	    }
	} catch (NoSuchElementException ex) {
	    return false;
	}
    }

    /**
     * @return a GregorianCalendar
     */
    public Object getObject(){
      return value;
    }

    /** 
     * Return an ISO 8601 string representing the date/time
     * represented by this Calendar.
     */
    public String toString(){
	return getString (value);
    }

    /**************************************************************************
     * A utility (static) method converting a GregorianCalendar object
     * to an ISO 8601 string. ISO 8601 date manipulation in Java 1.2
     * based on code by Simon Brooke
     * &lt;simon@jasmine.org.uk&gt;. Does not yet deal with fractions
     * of seconds.
     *************************************************************************/
    public static String getString (GregorianCalendar cvalue) {

	String timef = "'T'HH:mm:ss";
	String datef = "yyyy-MM-dd";
	String bothf = "yyyy-MM-dd'T'HH:mm:ss";
	boolean doTimeZone = true;
	
	String format = bothf;    // initially assume this is a date/time
	
	if((cvalue.isSet(Calendar.DAY_OF_MONTH) == false || cvalue.get(Calendar.DAY_OF_MONTH) == 1) &&
	   (cvalue.isSet(Calendar.MONTH) == false || cvalue.get(Calendar.MONTH) == 0) &&
	   (cvalue.isSet(Calendar.YEAR) == false || cvalue.get(Calendar.YEAR) == 1970)){
	    // it's highly probable that we're
	    // looking at a time-of-day.
	    format = timef;
	}
	else{
	    if((cvalue.isSet(Calendar.HOUR) == false || cvalue.get(Calendar.HOUR) == 0) &&
	       (cvalue.isSet(Calendar.MINUTE) == false || cvalue.get(Calendar.MINUTE) == 0) &&
	       (cvalue.isSet(Calendar.SECOND) == false || cvalue.get(Calendar.SECOND) == 0)){
		// It's highly probable that we're looking at a date
		format = datef;
		doTimeZone = false;
	    }
	}
	
	StringBuffer result = new StringBuffer((new SimpleDateFormat(format)).format(cvalue.getTime()));
	
	// We don't need to worry about timezone in date only strings but otherwise we do...
	if (doTimeZone){
	    int offsetTotalMillis = cvalue.getTimeZone().getOffset(cvalue.getTime().getTime());

	    // UTC, a.k.a. Zulu time
	    if (offsetTotalMillis == 0){ 
		result.append("Z");
	    }
	    // Some other timezone
	    else{        
		DecimalFormat decf = new DecimalFormat("00");
		
		int offsetTotalMinutes = offsetTotalMillis/60000;
		int offsetMinutes = offsetTotalMinutes % 60;
		int offsetHours = offsetTotalMinutes / 60;
		String offsetString = offsetTotalMinutes < 0 ? "-":"+";

		// We will maintain the time zone data, so roll back/forward the clock to GMT, and append
		// the offset data to the representation
		cvalue.add(Calendar.MINUTE, -offsetMinutes);
		cvalue.add(Calendar.HOUR_OF_DAY, -offsetHours);
		result = new StringBuffer(formatDateTime(cvalue));
		// Reset, so we don't mess things up permanently for the given calendar
		cvalue.add(Calendar.MINUTE, offsetMinutes);
		cvalue.add(Calendar.HOUR_OF_DAY, offsetHours);

		result.append(offsetString).append(decf.format(Math.abs(offsetHours)));
		result.append(":"+decf.format(Math.abs(offsetMinutes)));
	    }
	}
	
	return result.toString();
    }

    protected static String formatDateTime(Calendar ctime){
	DecimalFormat decf = new DecimalFormat("00");
	return
	    ctime.get(Calendar.YEAR)+"-"+
	    decf.format(ctime.get(Calendar.MONTH)+1)+"-"+
	    decf.format(ctime.get(Calendar.DAY_OF_MONTH))+"T"+
	    decf.format(ctime.get(Calendar.HOUR_OF_DAY))+":"+
	    decf.format(ctime.get(Calendar.MINUTE))+":"+
	    decf.format(ctime.get(Calendar.SECOND));
    }


    public MobyDataDateTime clone(){
	MobyDataDateTime copy = new MobyDataDateTime(getName(), getValue(), getDataType().getRegistry());
	copy.setDataType(getDataType());
	copy.setId(getId());
	copy.setNamespaces(getNamespaces());
	return copy;
    }

    public String getValue(){
	return toString();
    }

    /**
     * This class sanitizes strings of XML escape characters such as the ampersand (&amp;) and the 
     * less-than sign (&lt;).  WARNING: this method will not escape ampersand in the string "&amp;amp;", 
     * or '&amp;#x26;' style character references.  We will assume that is this case you've probably 
     * already written the string as XML. 
     *
     * WARNING: As of yet, we do not deal with the false escaping of strings containg already-escaped 
     * CDATA sections!     
     */
    public String toXML(){
	if(xmlMode == MobyDataInstance.SERVICE_XML_MODE){
	    return "<DateTime " + getAttrXML() + ">" + toString() + "</DateTime>";
        }
	else{
	    return super.toXML();
	}
    }
}
