/*   Copyright 2004 The Apache Software Foundation
 *
 *   Licensed under the Apache License, Version 2.0 (the "License");
 *   you may not use this file except in compliance with the License.
 *   You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *   Unless required by applicable law or agreed to in writing, software
 *   distributed under the License is distributed on an "AS IS" BASIS,
 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *   See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package org.apache.xmlbeans;

import org.apache.xmlbeans.impl.util.ExceptionUtil;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

/**
 * Represents an XML Schema-compatible Gregorian date.
 * <p>
 * There are many date types in XML Schema, and this type
 * represents the natural union of all those types.  A GDate
 * can hold any subset of date fields (Year, Month, Day, Time,
 * Timezone, or some combination). Wherever the specification
 * provides guidance, the guidelines in the
 * <a target="_blank" href="http://www.w3.org/TR/xmlschema-2/">XML Schema 1.0 specification</a>
 * (plus <a target="_blank" href="http://www.w3.org/2001/05/xmlschema-errata">published errata</a>) are followed.
 * <p>
 * Instances may separately have values or no values for
 * the year, month, day-of-month, and time-of-day. Not all
 * operations are meaningful on all combinations.
 */
public final class GDate implements GDateSpecification, java.io.Serializable {
    private static final long serialVersionUID = 1L;

    // XMLSchema spec requires support only for years 1 to 9999, but XMLBeans covers more up to the following limitations
    // to avoid losing precision when transforming to a java.util.Date
    static final int MAX_YEAR = 292277265;  // is Long.MAX_VALUE ms in years - 1 (for the 11month, 31days, 23h, 59m, 59sec case).
    static final int MIN_YEAR = -292275295; // is Long.MIN_VALUE ms in years + 1970 + 1

    // for fast equality comparison, hashing, and serialization
    private transient String _canonicalString;
    private transient String _string;

    private int _bits;
    private int _CY;
    private int _M;
    private int _D;
    private int _h;
    private int _m;
    private int _s;
    private BigDecimal _fs;
    private int _tzsign;
    private int _tzh;
    private int _tzm;


    /* package */ static final BigDecimal _zero = BigDecimal.ZERO;
    /* package */ static final BigDecimal _one = BigDecimal.ONE;

    /**
     * Constructs a GDate based on a lexical representation.
     */
    public GDate(CharSequence string) {
        // first trim XML whitespace
        int len = string.length();
        int start = 0;
        while (len > 0 && isSpace(string.charAt(len - 1))) {
            len -= 1;
        }
        while (start < len && isSpace(string.charAt(start))) {
            start += 1;
        }

        // pick optional timezone off the end
        if (len - start >= 1 && string.charAt(len - 1) == 'Z') {
            _bits |= HAS_TIMEZONE;
            len -= 1;
        } else if (len - start >= 6) {
            timezone:
            {
                int tzsign;
                int tzhour;
                int tzminute;

                if (string.charAt(len - 3) != ':') {
                    break timezone;
                }

                switch (string.charAt(len - 6)) {
                    case '-':
                        tzsign = -1;
                        break;
                    case '+':
                        tzsign = 1;
                        break;
                    default:
                        break timezone;
                }

                tzhour = twoDigit(string, len - 5);
                tzminute = twoDigit(string, len - 2);
                if (tzhour > 14) {
                    throw new IllegalArgumentException("time zone hour must be two digits between -14 and +14");
                }
                if (tzminute > 59) {
                    throw new IllegalArgumentException("time zone minute must be two digits between 00 and 59");
                }
                _bits |= HAS_TIMEZONE;
                _tzsign = tzsign;
                _tzh = tzhour;
                _tzm = tzminute;
                len -= 6;
            }
        }

        // pick date fields off the beginning if it doesn't look like a time
        if (start < len && (start + 2 >= len || string.charAt(start + 2) != ':')) {
            scandate:
            {
                // parse year sign
                boolean negyear = false;
                if (string.charAt(start) == '-') {
                    negyear = true;
                    start += 1;
                }

                // scan year digits
                int value = 0;
                int digits = -start;
                char ch;
                boolean startsWithZero = start < len && digitVal(string.charAt(start)) == 0;

                for (; ; ) {
                    ch = start < len ? string.charAt(start) : '\0';
                    if (!isDigit(ch)) {
                        break;
                    }

                    if (startsWithZero && start + digits >= 4) {
                        throw new IllegalArgumentException("year value starting with zero must be 4 or less digits: " + string);
                    }

                    value = value * 10 + digitVal(ch);
                    start += 1;
                }
                digits += start;
                if (digits > 9) {
                    throw new IllegalArgumentException("year too long (up to 9 digits)");
                } else if (digits >= 4) {
                    _bits |= HAS_YEAR;
                    _CY = negyear ? -value : value;
                    if (_CY == 0) {
                        throw new IllegalArgumentException("year must not be zero");
                    }
                } else if (digits > 0) {
                    throw new IllegalArgumentException("year must be four digits (may pad with zeroes, e.g., 0560)");
                }

                if (_CY > MAX_YEAR) {
                    throw new IllegalArgumentException("year value not supported: too big, must be less than " + MAX_YEAR);
                }

                if (_CY < MIN_YEAR) {
                    throw new IllegalArgumentException("year values not supported: too small, must be bigger than " + MIN_YEAR);
                }

                // hyphen introduces a month
                if (ch != '-') {
                    if (negyear && !hasYear()) {
                        throw new IllegalArgumentException(); // a single minus
                    } else {
                        break scandate;
                    }
                }
                start += 1;

                // two-digit month
                if (len - start >= 2) {
                    value = twoDigit(string, start);
                    if (value >= 1 && value <= 12) {
                        _bits |= HAS_MONTH;
                        _M = value;
                        start += 2;
                    }
                }

                // hyphen introduces a day
                ch = start < len ? string.charAt(start) : '\0';
                if (ch != '-') {
                    if (!hasMonth()) {
                        throw new IllegalArgumentException(); // minus after a year
                    } else {
                        break scandate;
                    }
                }
                start += 1;

                // two-digit day
                if (len - start >= 2) {
                    value = twoDigit(string, start);
                    if (value >= 1 && value <= 31) {
                        _bits |= HAS_DAY;
                        _D = value;
                        start += 2;
                    }
                }

                if (!hasDay()) {
                    // error in the original schema spec permits an extra '-' here
                    if (hasMonth() && !hasYear()) {
                        ch = start < len ? string.charAt(start) : '\0';
                        if (ch == '-') {
                            start += 1;
                            break scandate;
                        }
                    }
                    throw new IllegalArgumentException(); // minus after a month
                }
            }
        }

        // time
        if (start < len) {
            if (hasYear() || hasMonth() || hasDay()) {
                if (string.charAt(start) != 'T') {
                    throw new IllegalArgumentException("date and time must be separated by 'T'");
                }
                start += 1;
            }

            if (len < start + 8 || string.charAt(start + 2) != ':' || string.charAt(start + 5) != ':') {
                throw new IllegalArgumentException();
            }

            int h = twoDigit(string, start);
            if (h > 24) {
                throw new IllegalArgumentException("hour must be between 00 and 23");
            }
            int m = twoDigit(string, start + 3);
            if (m >= 60) {
                throw new IllegalArgumentException("minute must be between 00 and 59");
            }
            int s = twoDigit(string, start + 6);
            if (s >= 60) {
                throw new IllegalArgumentException("second must be between 00 and 59");
            }

            start += 8;

            BigDecimal fs = _zero;
            if (start < len) {
                if (string.charAt(start) != '.') {
                    throw new IllegalArgumentException();
                }
                if (start + 1 < len) {
                    for (int i = start + 1; i < len; i++) {
                        if (!isDigit(string.charAt(i))) {
                            throw new IllegalArgumentException();
                        }
                    }
                    try {
                        fs = new BigDecimal(string.subSequence(start, len).toString());
                    } catch (Throwable e) {
                        if (ExceptionUtil.isFatal(e)) {
                            ExceptionUtil.rethrow(e);
                        }
                        throw new IllegalArgumentException();
                    }
                }
            }

            _bits |= HAS_TIME;
            _h = h;
            _m = m;
            _s = s;
            _fs = fs;
        }

        if (hasTime() && _h == 24) {
            if (_m != 0 || _s != 0 || _fs.compareTo(_zero) != 0) {
                throw new IllegalArgumentException("if hour is 24, minutes, seconds and fraction must be 0");
            } else {   // normalize to next day if it has date or at least has day
                if (hasDate()) {
                    GDateBuilder gdb = new GDateBuilder(_CY, _M, _D, _h, _m, _s, _fs, _tzsign, _tzh, _tzm);
                    gdb.normalize24h();

                    _D = gdb.getDay();
                    _M = gdb.getMonth();
                    _CY = gdb.getYear();
                    _h = 0;
                } else if (hasDay()) // if no date only days increment
                {
                    _D++;
                    _h = 0;
                }
            }
        }

        if (!isValid()) {
            throw new IllegalArgumentException("invalid date");
        }
    }

    /**
     * Constructs a GDate with the specified year, month, day,
     * hours, minutes, seconds, and optional fractional seconds, in
     * an unspecified timezone.
     * <p>
     * Note that by not specifying the timezone the GDate
     * becomes partially unordered with respect to times that
     * do have a specified timezone.
     */
    public GDate(
        int year,
        int month,
        int day,
        int hour,
        int minute,
        int second,
        BigDecimal fraction) {
        _bits = HAS_YEAR | HAS_MONTH | HAS_DAY | HAS_TIME;

        _CY = year;
        _M = month;
        _D = day;
        _h = hour;
        _m = minute;
        _s = second;
        _fs = fraction == null ? _zero : fraction;

        if (!isValid()) {
            throw new IllegalArgumentException();
        }
    }

    /**
     * Constructs an absolute GDate with the specified year,
     * month, day, hours, minutes, seconds, and optional fractional
     * seconds, and in the timezone specified.
     * <p>
     * If you wish to have a time or date that isn't in a specified timezone,
     * then use the constructor that does not include the timezone arguments.
     */
    public GDate(
        int year,
        int month,
        int day,
        int hour,
        int minute,
        int second,
        BigDecimal fraction,
        int tzSign,
        int tzHour,
        int tzMinute) {
        _bits = HAS_TIMEZONE | HAS_YEAR | HAS_MONTH | HAS_DAY | HAS_TIME;

        _CY = year;
        _M = month;
        _D = day;
        _h = hour;
        _m = minute;
        _s = second;
        _fs = fraction == null ? _zero : fraction;
        _tzsign = tzSign;
        _tzh = tzHour;
        _tzm = tzMinute;

        if (!isValid()) {
            throw new IllegalArgumentException();
        }
    }

    /**
     * Constructs a GDate based on a java.util.Date.
     * <p>
     * The current offset of the default timezone is used as the timezone.
     * <p>
     * For example, if eastern daylight time is in effect at the given
     * date, the timezone on the east coast of the United States
     * translates to GMT-05:00 (EST) + 1:00 (DT offset) == GMT-04:00.
     */
    public GDate(Date date) {
        // requires some date math, so ctor lives on GDateBuilder
        this(new GDateBuilder(date));
    }

    /**
     * Constructs a GDate based on a java.util.Calendar.
     * <p>
     * If the calendar does not have some fields set, the same absence
     * of information is reflected in the GDate.  Note that
     * java.util.GregorianCalendar fills in all fields as soon as any
     * are fetched, so constructing a GDate with the same calendar object
     * twice may result in a different GDate because of a changed calendar.
     * Note that org.apache.xmlbeans.XmlCalendar is stable if you re-get a set field,
     * so it does not have the same problem.
     */
    public GDate(Calendar calendar) {
        // we must scrape the "isSet" information out before accessing anything
        boolean isSetYear = calendar.isSet(Calendar.YEAR);
        boolean isSetEra = calendar.isSet(Calendar.ERA);
        boolean isSetMonth = calendar.isSet(Calendar.MONTH);
        boolean isSetDay = calendar.isSet(Calendar.DAY_OF_MONTH);
        boolean isSetHourOfDay = calendar.isSet(Calendar.HOUR_OF_DAY);
        boolean isSetHour = calendar.isSet(Calendar.HOUR);
        boolean isSetAmPm = calendar.isSet(Calendar.AM_PM);
        boolean isSetMinute = calendar.isSet(Calendar.MINUTE);
        boolean isSetSecond = calendar.isSet(Calendar.SECOND);
        boolean isSetMillis = calendar.isSet(Calendar.MILLISECOND);
        boolean isSetZone = calendar.isSet(Calendar.ZONE_OFFSET);
        boolean isSetDst = calendar.isSet(Calendar.DST_OFFSET);

        if (isSetYear) {
            int y = calendar.get(Calendar.YEAR);
            if (isSetEra && calendar instanceof GregorianCalendar) {
                if (calendar.get(Calendar.ERA) == GregorianCalendar.BC) {
                    y = -y; //1 - y;
                }
            }
            _bits |= HAS_YEAR;
            _CY = y;
        }
        if (isSetMonth) {
            _bits |= HAS_MONTH;
            _M = calendar.get(Calendar.MONTH) + 1; // !!note
        }
        if (isSetDay) {
            _bits |= HAS_DAY;
            _D = calendar.get(Calendar.DAY_OF_MONTH);
        }
        boolean gotTime = false;

        int h = 0;
        int m = 0;
        int s = 0;
        BigDecimal fs = _zero;

        if (isSetHourOfDay) {
            h = calendar.get(Calendar.HOUR_OF_DAY);
            gotTime = true;
        } else if (isSetHour && isSetAmPm) {
            h = calendar.get(Calendar.HOUR) + calendar.get(Calendar.AM_PM) * 12;
            gotTime = true;
        }

        if (isSetMinute) {
            m = calendar.get(Calendar.MINUTE);
            gotTime = true;
        }

        if (isSetSecond) {
            s = calendar.get(Calendar.SECOND);
            gotTime = true;
        }

        if (isSetMillis) {
            fs = BigDecimal.valueOf(calendar.get(Calendar.MILLISECOND), 3);
            gotTime = true;
        }

        if (gotTime) {
            _bits |= HAS_TIME;
            _h = h;
            _m = m;
            _s = s;
            _fs = fs;
        }

        if (isSetZone) {
            int zoneOffsetInMilliseconds = calendar.get(Calendar.ZONE_OFFSET);
            if (isSetDst) {
                zoneOffsetInMilliseconds += calendar.get(Calendar.DST_OFFSET);
            }

            _bits |= HAS_TIMEZONE;
            if (zoneOffsetInMilliseconds == 0) {
                _tzsign = 0;
                _tzh = 0;
                _tzm = 0;
                TimeZone zone = calendar.getTimeZone();
                String id = zone.getID();
                if (id != null && id.length() > 3) {
                    switch (id.charAt(3)) {
                        case '+':
                            _tzsign = 1;
                            break;   // GMT+00:00
                        case '-':
                            _tzsign = -1;
                            break;  // GMT-00:00
                    }
                }
            } else {
                _tzsign = (zoneOffsetInMilliseconds < 0 ? -1 : +1);
                zoneOffsetInMilliseconds = zoneOffsetInMilliseconds * _tzsign;
                _tzh = zoneOffsetInMilliseconds / 3600000;
                _tzm = (zoneOffsetInMilliseconds - _tzh * 3600000) / 60000;
            }
        }
    }

    /**
     * Constructs a GDate based on another GDateSpecification.
     */
    public GDate(GDateSpecification gdate) {
        if (gdate.hasTimeZone()) {
            _bits |= HAS_TIMEZONE;
            _tzsign = gdate.getTimeZoneSign();
            _tzh = gdate.getTimeZoneHour();
            _tzm = gdate.getTimeZoneMinute();
        }

        if (gdate.hasTime()) {
            _bits |= HAS_TIME;
            _h = gdate.getHour();
            _m = gdate.getMinute();
            _s = gdate.getSecond();
            _fs = gdate.getFraction();
        }

        if (gdate.hasDay()) {
            _bits |= HAS_DAY;
            _D = gdate.getDay();
        }

        if (gdate.hasMonth()) {
            _bits |= HAS_MONTH;
            _M = gdate.getMonth();
        }

        if (gdate.hasYear()) {
            _bits |= HAS_YEAR;
            _CY = gdate.getYear();
        }
    }

    /* package */
    static boolean isDigit(char ch) {
        return ((char) (ch - '0') <= '9' - '0'); // char is unsigned
    }

    /* package */
    static boolean isSpace(char ch) {
        switch (ch) {
            case ' ':
            case '\t':
            case '\r':
            case '\n':
                return true;
            default:
                return false;
        }
    }

    /* package */
    static int digitVal(char ch) {
        return (ch - '0');
    }

    private static int twoDigit(CharSequence str, int index) {
        char ch1 = str.charAt(index);
        char ch2 = str.charAt(index + 1);
        if (!isDigit(ch1) || !isDigit(ch2)) {
            return 100; // not two digits
        }
        return digitVal(ch1) * 10 + digitVal(ch2);
    }

    /**
     * Returns true: all GDate instances are immutable.
     */
    public final boolean isImmutable() {
        return true;
    }

    /**
     * Returns a combination of flags indicating the information
     * contained by this GDate.  The five flags are
     * HAS_TIMEZONE, HAS_YEAR, HAS_MONTH, HAS_DAY, and HAS_TIME.
     */
    public int getFlags() {
        return _bits;
    }

    /**
     * True if this date/time specification specifies a timezone.
     */
    public final boolean hasTimeZone() {
        return ((_bits & HAS_TIMEZONE) != 0);
    }

    /**
     * True if this date/time specification specifies a year.
     */
    public final boolean hasYear() {
        return ((_bits & HAS_YEAR) != 0);
    }

    /**
     * True if this date/time specification specifies a month-of-year.
     */
    public final boolean hasMonth() {
        return ((_bits & HAS_MONTH) != 0);
    }

    /**
     * True if this date/time specification specifies a day-of-month.
     */
    public final boolean hasDay() {
        return ((_bits & HAS_DAY) != 0);
    }

    /**
     * True if this date/time specification specifies a time-of-day.
     */
    public final boolean hasTime() {
        return ((_bits & HAS_TIME) != 0);
    }

    /**
     * True if this date/time specification specifies a full date (year, month, day)
     */
    public final boolean hasDate() {
        return ((_bits & (HAS_DAY | HAS_MONTH | HAS_YEAR)) == (HAS_DAY | HAS_MONTH | HAS_YEAR));
    }

    /**
     * Gets the year. Should be a four-digit year specification.
     */
    public final int getYear() {
        return _CY;
    }

    /**
     * Gets the month-of-year. January is 1.
     */
    public final int getMonth() {
        return _M;
    }

    /**
     * Gets the day-of-month. The first day of each month is 1.
     */
    public final int getDay() {
        return _D;
    }

    /**
     * Gets the hour-of-day. Midnight is 0, and 11PM is 23.
     */
    public final int getHour() {
        return _h;
    }

    /**
     * Gets the minute-of-hour. Range from 0 to 59.
     */
    public final int getMinute() {
        return _m;
    }

    /**
     * Gets the second-of-minute. Range from 0 to 59.
     */
    public final int getSecond() {
        return _s;
    }

    /**
     * Gets the fraction-of-second. Range from 0 (inclusive) to 1 (exclusive).
     */
    public final BigDecimal getFraction() {
        return _fs;
    }

    /**
     * Gets the time zone sign. For time zones east of GMT,
     * this is positive; for time zones west, this is negative.
     */
    public final int getTimeZoneSign() {
        return _tzsign;
    }

    /**
     * Gets the time zone hour.
     * <p>
     * This is always positive: for the sign, look at
     * getTimeZoneSign().
     */
    public final int getTimeZoneHour() {
        return _tzh;
    }

    /**
     * Gets the time zone minutes.
     * <p>
     * This is always positive: for the sign, look at
     * getTimeZoneSign().
     */
    public final int getTimeZoneMinute() {
        return _tzm;
    }

    /**
     * Gets the rounded millisecond value. Range from 0 to 999
     */
    public int getMillisecond() {
        if (_fs == null) {
            return 0;
        }
        return _fs.setScale(3, RoundingMode.DOWN).unscaledValue().intValue();
    }

    /**
     * The canonical string representation. Specific moments or
     * times-of-day in a specified timezone are normalized to
     * UTC time to produce a canonical string form for them.
     * Other recurring time specifications keep their timezone
     * information.
     */
    public String canonicalString() {
        ensureCanonicalString();
        return _canonicalString;
    }

    /**
     * True if this GDate corresponds to a valid gregorian date value
     * in XML schema.
     */
    public boolean isValid() {
        return GDateBuilder.isValidGDate(this);
    }

    /**
     * Returns the Julian date corresponding to this Gregorian date.
     * The Julian date (JD) is a continuous count of days from
     * 1 January 4713 BC.
     */
    public int getJulianDate() {
        return GDateBuilder.julianDateForGDate(this);
    }

    /**
     * Retrieves the value of the current time as an {@link XmlCalendar}.
     * <p>
     * {@link XmlCalendar} is a subclass of {@link java.util.GregorianCalendar}
     * which is slightly customized to match XML schema date rules.
     * <p>
     * The returned {@link XmlCalendar} has only those time and date fields
     * set that are reflected in the GDate object.  Because of the way the
     * {@link java.util.Calendar} contract works, any information in the isSet() vanishes
     * as soon as you view any unset field using get() methods.
     * This means that if it is important to understand which date fields
     * are set, you must call isSet() first before get().
     */
    public XmlCalendar getCalendar() {
        return new XmlCalendar(this);
    }


    /**
     * Retrieves the value of the current time as a java.util.Date
     * instance.
     */
    public Date getDate() {
        return GDateBuilder.dateForGDate(this);
    }

    /**
     * Comparison to another GDate.
     * <ul>
     * <li>Returns -1 if this &lt; date. (less-than)
     * <li>Returns 0 if this == date. (equal)
     * <li>Returns 1 if this &gt; date. (greater-than)
     * <li>Returns 2 if this &lt;&gt; date. (incomparable)
     * </ul>
     * Two instances are incomparable if they have different amounts
     * of information.
     */
    public int compareToGDate(GDateSpecification datespec) {
        return GDateBuilder.compareGDate(this, datespec);
    }

    /**
     * Returns the builtin type code for the shape of the information
     * contained in this instance, or 0 if the
     * instance doesn't contain information corresponding to a
     * Schema type.
     * <p>
     * Value will be equal to
     * {@link SchemaType#BTC_NOT_BUILTIN},
     * {@link SchemaType#BTC_G_YEAR},
     * {@link SchemaType#BTC_G_YEAR_MONTH},
     * {@link SchemaType#BTC_G_MONTH},
     * {@link SchemaType#BTC_G_MONTH_DAY},
     * {@link SchemaType#BTC_G_DAY},
     * {@link SchemaType#BTC_DATE},
     * {@link SchemaType#BTC_DATE_TIME}, or
     * {@link SchemaType#BTC_TIME}.
     */
    public int getBuiltinTypeCode() {
        return GDateBuilder.btcForFlags(_bits);
    }

    /**
     * Adds a duration to this GDate, and returns a new GDate.
     */
    public GDate add(GDurationSpecification duration) {
        GDateBuilder builder = new GDateBuilder(this);
        builder.addGDuration(duration);
        return builder.toGDate();
    }

    /**
     * Adds a duration to this GDate, and returns a new GDate.
     */
    public GDate subtract(GDurationSpecification duration) {
        GDateBuilder builder = new GDateBuilder(this);
        builder.subtractGDuration(duration);
        return builder.toGDate();
    }

    /**
     * GDate is an immutable class, and equality is computed based
     * on its canonical value.
     */
    public boolean equals(Object obj) {
        if (obj == this) {
            return true;
        }
        if (!(obj instanceof GDate)) {
            return false;
        }

        ensureCanonicalString();
        return _canonicalString.equals(((GDate) obj).canonicalString());
    }

    /**
     * Returns a hash code for this GDate.
     */
    public int hashCode() {
        ensureCanonicalString();
        return _canonicalString.hashCode();
    }

    /**
     * The canonical string representation. Specific moments or
     * times-of-day in a specified timezone are normalized to
     * UTC time to produce a canonical string form for them.
     * Other recurring time specifications keep their timezone
     * information.
     */
    private void ensureCanonicalString() {
        if (_canonicalString != null) {
            return;
        }

        boolean needNormalize =
            (hasTimeZone() && getTimeZoneSign() != 0 && hasTime() &&
             ((hasDay() == hasMonth() && hasDay() == hasYear())));

        if (!needNormalize && getFraction() != null && getFraction().scale() > 0) {
            BigInteger bi = getFraction().unscaledValue();
            needNormalize = (bi.mod(GDateBuilder.TEN).signum() == 0);
        }

        if (!needNormalize) {
            _canonicalString = toString();
        } else {
            GDateBuilder gdb = new GDateBuilder(this);
            gdb.normalize();
            _canonicalString = gdb.toString();
        }
    }

    /**
     * The natural string representation. This represents the information
     * that is available, including timezone. For types that correspond
     * to defined schema types (schemaBuiltinTypeCode() &gt; 0),
     * this provides the natural lexical representation.
     * <p>
     * When both time and timezone are specified, this string is not
     * the canonical representation unless the timezone is UTC (Z)
     * (since the same moment in time can be expressed in different
     * timezones). To get a canonical string, use the canonicalString()
     * method.
     */
    public String toString() {
        if (_string == null) {
            _string = formatGDate(this);
        }
        return _string;
    }

    private final static char[] _tensDigit =
        {
            '0', '0', '0', '0', '0', '0', '0', '0', '0', '0',
            '1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
            '2', '2', '2', '2', '2', '2', '2', '2', '2', '2',
            '3', '3', '3', '3', '3', '3', '3', '3', '3', '3',
            '4', '4', '4', '4', '4', '4', '4', '4', '4', '4',
            '5', '5', '5', '5', '5', '5', '5', '5', '5', '5',
            '6', '6', '6', '6', '6', '6', '6', '6', '6', '6',
            '7', '7', '7', '7', '7', '7', '7', '7', '7', '7',
            '8', '8', '8', '8', '8', '8', '8', '8', '8', '8',
            '9', '9', '9', '9', '9', '9', '9', '9', '9', '9',
        };
    private final static char[] _onesDigit =
        {
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
        };

    private static int _padTwoAppend(char[] b, int i, int n) {
        assert (n >= 0 && n < 100);
        b[i] = _tensDigit[n];
        b[i + 1] = _onesDigit[n];
        return i + 2;
    }

    private static int _padFourAppend(char[] b, int n) {
        int i = 0;
        if (n < 0) {
            b[i++] = '-';
            n = -n;
        }
        if (n >= 10000) {
            String s = Integer.toString(n);
            s.getChars(0, s.length(), b, i);
            return i + s.length();
        }
        int q = n / 100;
        int r = n - q * 100;
        b[i] = _tensDigit[q];
        b[i + 1] = _onesDigit[q];
        b[i + 2] = _tensDigit[r];
        b[i + 3] = _onesDigit[r];
        return i + 4;
    }

    private static final TimeZone GMTZONE = TimeZone.getTimeZone("GMT");
    private static final TimeZone[] MINUSZONE =
        {
            TimeZone.getTimeZone("GMT-00:00"),
            TimeZone.getTimeZone("GMT-01:00"),
            TimeZone.getTimeZone("GMT-02:00"),
            TimeZone.getTimeZone("GMT-03:00"),
            TimeZone.getTimeZone("GMT-04:00"),
            TimeZone.getTimeZone("GMT-05:00"),
            TimeZone.getTimeZone("GMT-06:00"),
            TimeZone.getTimeZone("GMT-07:00"),
            TimeZone.getTimeZone("GMT-08:00"),
            TimeZone.getTimeZone("GMT-09:00"),
            TimeZone.getTimeZone("GMT-10:00"),
            TimeZone.getTimeZone("GMT-11:00"),
            TimeZone.getTimeZone("GMT-12:00"),
            TimeZone.getTimeZone("GMT-13:00"),
            TimeZone.getTimeZone("GMT-14:00"),
        };
    private static final TimeZone[] PLUSZONE =
        {
            TimeZone.getTimeZone("GMT+00:00"),
            TimeZone.getTimeZone("GMT+01:00"),
            TimeZone.getTimeZone("GMT+02:00"),
            TimeZone.getTimeZone("GMT+03:00"),
            TimeZone.getTimeZone("GMT+04:00"),
            TimeZone.getTimeZone("GMT+05:00"),
            TimeZone.getTimeZone("GMT+06:00"),
            TimeZone.getTimeZone("GMT+07:00"),
            TimeZone.getTimeZone("GMT+08:00"),
            TimeZone.getTimeZone("GMT+09:00"),
            TimeZone.getTimeZone("GMT+10:00"),
            TimeZone.getTimeZone("GMT+11:00"),
            TimeZone.getTimeZone("GMT+12:00"),
            TimeZone.getTimeZone("GMT+13:00"),
            TimeZone.getTimeZone("GMT+14:00"),
        };

    /* package */
    static TimeZone timeZoneForGDate(GDateSpecification date) {
        // use a cached timezone if integral; otherwise make a new one.
        if (!date.hasTimeZone()) {
            return TimeZone.getDefault();
        }
        if (date.getTimeZoneSign() == 0) {
            return GMTZONE;
        }
        if (date.getTimeZoneMinute() == 0 && date.getTimeZoneHour() <= 14 && date.getTimeZoneHour() >= 0) {
            return date.getTimeZoneSign() < 0 ? MINUSZONE[date.getTimeZoneHour()] : PLUSZONE[date.getTimeZoneHour()];
        }

        char[] zb = new char[9];
        zb[0] = 'G';
        zb[1] = 'M';
        zb[2] = 'T';
        zb[3] = (date.getTimeZoneSign() < 0) ? '-' : '+';
        GDate._padTwoAppend(zb, 4, date.getTimeZoneHour());
        zb[6] = ':';
        GDate._padTwoAppend(zb, 7, date.getTimeZoneMinute());
        return TimeZone.getTimeZone(new String(zb));
    }

    /* package */
    static String formatGDate(GDateSpecification spec) {
        // We've used a char[] rather than a StringBuffer for a 4x speedup
        // -YY(10)YY-MM-DDTHH:MM:SS.FFFFFF+ZH:ZM
        // 1 + 10   + 3+ 3+ 3+ 3+ 3+1 + s + 3+ 3 = 33 + s
        BigDecimal fs = spec.getFraction();
        char[] message = new char[33 + (fs == null ? 0 : fs.scale())];
        int i = 0;

        if (spec.hasYear() || spec.hasMonth() || spec.hasDay()) {
            dmy:
            {
                if (spec.hasYear()) {
                    i = _padFourAppend(message, spec.getYear());
                } else {
                    message[i++] = '-';
                }

                if (!(spec.hasMonth() || spec.hasDay())) {
                    break dmy;
                }

                message[i++] = '-';
                if (spec.hasMonth()) {
                    i = _padTwoAppend(message, i, spec.getMonth());
                }

                if (!spec.hasDay()) {
                    break dmy;
                }

                message[i++] = '-';
                i = _padTwoAppend(message, i, spec.getDay());
            }
            if (spec.hasTime()) {
                message[i++] = 'T';
            }
        }

        if (spec.hasTime()) {
            i = _padTwoAppend(message, i, spec.getHour());
            message[i++] = ':';
            i = _padTwoAppend(message, i, spec.getMinute());
            message[i++] = ':';
            i = _padTwoAppend(message, i, spec.getSecond());
            if (fs != null && !_zero.equals(fs)) // (optimization ~3%)
            {
                String frac = fs.toString();
                int point = frac.indexOf('.');
                if (point >= 0) {
                    frac.getChars(point, frac.length(), message, i);
                    i += frac.length() - point;
                }
            }
        }

        if (spec.hasTimeZone()) {
            if (spec.getTimeZoneSign() == 0) {
                message[i++] = 'Z';
            } else {
                message[i++] = spec.getTimeZoneSign() > 0 ? '+' : '-';
                i = _padTwoAppend(message, i, spec.getTimeZoneHour());
                message[i++] = ':';
                i = _padTwoAppend(message, i, spec.getTimeZoneMinute());
            }
        }

        // it would be nice to use (0, i, message) ctor instead
        return new String(message, 0, i);
    }

}

