package at.gv.egovernment.moa.util;

import java.io.StringWriter;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

/**
 * Utility for parsing and building XML type <code>dateTime</code>,
 * according to ISO 8601.
 * 
 * @author Patrick Peck
 * @version $Id$
 * @see <code>http://www.w3.org/2001/XMLSchema-datatypes"</code>
 */
public class DateTimeUtils {
  /** Error messages. */
  private static MessageProvider msg = MessageProvider.getInstance();

  /**
   * Builds a <code>dateTime</code> value from a <code>Calendar</code> value.
   * @param cal the <code>Calendar</code> value
   * @return the <code>dateTime</code> value
   */
  public static String buildDateTime(Calendar cal) {
    StringWriter out = new StringWriter();
    out.write("" + cal.get(Calendar.YEAR));
    out.write("-");
    out.write(to2DigitString(cal.get(Calendar.MONTH) + 1));
    out.write("-");
    out.write(to2DigitString(cal.get(Calendar.DAY_OF_MONTH)));
    out.write("T");
    out.write(to2DigitString(cal.get(Calendar.HOUR_OF_DAY)));
    out.write(":");
    out.write(to2DigitString(cal.get(Calendar.MINUTE)));
    out.write(":");
    out.write(to2DigitString(cal.get(Calendar.SECOND)));
    int tzOffsetMilliseconds =
      cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET);
    if (tzOffsetMilliseconds != 0) {
      int tzOffsetMinutes = tzOffsetMilliseconds / (1000 * 60);
      int tzOffsetHours = tzOffsetMinutes / 60;
      tzOffsetMinutes -= tzOffsetHours * 60;
      if (tzOffsetMilliseconds > 0) {
        out.write("+");
        out.write(to2DigitString(tzOffsetHours));
        out.write(":");
        out.write(to2DigitString(tzOffsetMinutes));
      } else {
        out.write("-");
        out.write(to2DigitString(-tzOffsetHours));
        out.write(":");
        out.write(to2DigitString(-tzOffsetMinutes));
      }
    }
    return out.toString();
  }
  
  /**
   * Converts month, day, hour, minute, or second value
   * to a 2 digit String.
   * @param number the month, day, hour, minute, or second value
   * @return 2 digit String
   */
  private static String to2DigitString(int number) {
    if (number < 10)
      return "0" + number;
    else
      return "" + number;
  }

  /**
   * Parse a <code>String</code> containing a date and time instant, given in
   * ISO 8601 format.
   * 
   * @param dateTime The <code>String</code> to parse.
   * @return The <code>Date</code> representation of the contents of
   * <code>dateTime</code>.
   * @throws ParseException Parsing the <code>dateTime</code> failed.
   */
  public static Date parseDateTime(String dateTime) throws ParseException {
    GregorianCalendar calendar;
    long time;
    int yearSign = 1, year, month, day;
    int hour, minute, second;
    double fraction = 0.0;
    int tzSign = 1, tzHour = 0, tzMinute = 0;
    int curPos = 0;
    String fractStr;
    boolean localTime = false;
    char c;

    // parse year sign
    ensureChars(dateTime, curPos, 1);
    c = dateTime.charAt(curPos);
    if (c == '+' || c == '-') {
      yearSign = c == '+' ? 1 : -1;
      curPos++;
    }

    // parse year
    year = parseInt(dateTime, curPos, 4);
    curPos += 4;

    // parse '-'
    ensureChar(dateTime, curPos, '-');
    curPos++;

    // parse month
    month = parseInt(dateTime, curPos, 2);
    ensureValue(month, 1, 12, curPos);
    curPos += 2;

    // parse '-'
    ensureChar(dateTime, curPos, '-');
    curPos++;

    // parse day
    day = parseInt(dateTime, curPos, 2);
    ensureValue(day, 1, 31, curPos);
    curPos += 2;

    // parse 'T'
    ensureChar(dateTime, curPos, 'T');
    curPos++;

    // parse hour
    hour = parseInt(dateTime, curPos, 2);
    ensureValue(hour, 0, 23, curPos);
    curPos += 2;

    // parse ':'
    ensureChar(dateTime, curPos, ':');
    curPos++;

    // parse minute
    minute = parseInt(dateTime, curPos, 2);
    ensureValue(minute, 0, 59, curPos);
    curPos += 2;

    // parse ':'
    ensureChar(dateTime, curPos, ':');
    curPos++;

    // parse second
    second = parseInt(dateTime, curPos, 2);
    ensureValue(second, 0, 59, curPos);
    curPos += 2;

    // parse a fraction
    if (dateTime.length() > curPos && dateTime.charAt(curPos) == '.') {
      curPos++;
      ensureDigits(dateTime, curPos, 1);
      fractStr = "0.";
      fractStr
        += dateTime.substring(curPos, curPos + countDigits(dateTime, curPos));
      fraction = Double.parseDouble(fractStr);
      curPos += countDigits(dateTime, curPos);
    }

    // parse a time zone
    if (dateTime.length() > curPos) {
      c = dateTime.charAt(curPos);
      if (c == 'Z') {
        curPos++;
      } else if (c == '+' || c == '-') {
        // parse time zone sign
        tzSign = c == '+' ? 1 : -1;
        curPos++;

        // parse time zone hour
        tzHour = parseInt(dateTime, curPos, 2);
        ensureValue(tzHour, 0, 14, curPos);
        curPos += 2;

        // parse ':'
        ensureChar(dateTime, curPos, ':');
        curPos++;

        // parse time zone minute
        tzMinute = parseInt(dateTime, curPos, 2);
        ensureValue(tzMinute, 0, 59, curPos);
        curPos += 2;
      }
    } else {
      localTime = true;
    }

    // if we have characters left, it's an error
    if (dateTime.length() != curPos) {
      throw new ParseException(msg.getMessage("datetime.00", null), curPos);
    }

    // build the Date object
    year = year * yearSign;
    try {
      calendar = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
      calendar.set(year, month - 1, day, hour, minute, second);
      calendar.set(Calendar.MILLISECOND, 0);
      time = calendar.getTime().getTime();
      time += (long) (fraction * 1000.0);
      time -= tzSign * ((tzHour * 60) + tzMinute) * 60 * 1000;
      if (localTime) {
        time -= TimeZone.getDefault().getRawOffset();        
      }
      return new Date(time);
    } catch (IllegalArgumentException e) {
      throw new ParseException(msg.getMessage("datetime.00", null), curPos);
    }

  }

  /**
   * Parse an integer value.
   * 
   * @param str The <code>String</code> containing the digits.
   * @param curPos The starting position.
   * @param digits The number of digist making up the integer value.
   * @return int The integer representation of the digits contained in
   * <code>str</code>.
   * @throws ParseException Parsing the integer value failed.
   */
  private static int parseInt(String str, int curPos, int digits)
    throws ParseException {

    ensureDigits(str, curPos, digits);
    return Integer.parseInt(str.substring(curPos, curPos + digits));
  }

  /**
   * Count the number of digits following <code>curPos</code>.
   * 
   * @param str The <code>String</code> in which to count digits.
   * @param curPos The starting position.
   * @return int The number of digits.
   */
  private static int countDigits(String str, int curPos) {
    int i;

    for (i = curPos; i < str.length() && Character.isDigit(str.charAt(i)); i++);
    return i - curPos;
  }

  /**
   * Ensure that a value falls in a given min/max range.
   * 
   * @param value The value to check.
   * @param min The minimum allowed value.
   * @param max The maximum allowed value.
   * @param curPos To indicate the parsing position in the
   * <code>ParseException</code>.
   * @throws ParseException Thrown, if <code>value &lt; min || value &gt;
   * max</code>
   */
  private static void ensureValue(int value, int min, int max, int curPos)
    throws ParseException {

    if (value < min || value > max) {
      throw new ParseException(msg.getMessage("datetime.00", null), curPos);
    }
  }

  /**
   * Ensure that the given <code>String</code> has a number of characters left.
   * 
   * @param str The <code>String</code> to check for its length.
   * @param curPos The starting position.
   * @param count The minimum number of characters that <code>str</code> must
   * contain, starting at from <code>curPos</code>.
   * @throws ParseException Thrown, if 
   * <code>curPos + count &gt; str.length()</code>.
   */
  private static void ensureChars(String str, int curPos, int count)
    throws ParseException {
    if (curPos + count > str.length()) {
      throw new ParseException(msg.getMessage("datetime.00", null), curPos);
    }
  }

  /**
   * Ensure that a given <code>String</code> contains a certain character at a
   * certain position.
   * 
   * @param str The <code>String</code> in which to look up the character. 
   * @param curPos The position in <code>str</code> that must contain the
   * character.
   * @param c The character value that must be contained at position
   * <code>curPos</code>.
   * @throws ParseException Thrown, if the characters do not match or
   * <code>curPos</code> is out of range.
   */
  private static void ensureChar(String str, int curPos, char c)
    throws ParseException {

    ensureChars(str, curPos, 1);
    if (str.charAt(curPos) != c) {
      throw new ParseException(msg.getMessage("datetime.00", null), curPos);
    }
  }

  /**
   * Ensure that a given <code>String</code> contains a number of digits,
   * starting at a given position.
   * 
   * @param str The <code>String</code> to scan for digits. 
   * @param curPos The starting postion.
   * @param count The number of digits that must be contained in
   * <code>str</code>, starting at <code>curPos</code>.
   * @throws ParseException Thrown, if <code>str</code> is not long enough, or
   * one of the characters following <code>curPos</code> in <code>str</code> is
   * not a digit.
   */
  private static void ensureDigits(String str, int curPos, int count)
    throws ParseException {

    ensureChars(str, curPos, count);
    for (int i = curPos; i < curPos + count; i++) {
      if (!Character.isDigit(str.charAt(i))) {
        throw new ParseException(msg.getMessage("datetime.00", null), curPos);
      }
    }
  }

}