package com.srbenoit.util;

/**
 * A set of fields storing a local date and time (does not store a Java timestamp or a time zone).
 */
public class LocalTime {

    /** error message when time string is invalid */
    private static final String ERR = "Invlalid LocalTime string";

    /** an integer that indicates a field has no value */
    public static final int NO_VALUE = -1;

    /** the year (range 1-9999), -1 means no value */
    private int year = NO_VALUE;

    /** the month, where January is 1 (range 1-12), -1 means no value */
    private int month = NO_VALUE;

    /** the day of the month (range 1-31), -1 means no value */
    private int day = NO_VALUE;

    /** the hour of the day (range 0-23), -1 means no value */
    private int hour = NO_VALUE;

    /** the minute of the hour (range 0-59), -1 means no value */
    private int minute = NO_VALUE;

    /** the second (range 0-59), -1 means no value */
    private int second = NO_VALUE;

    /** milliseconds (range 0-999), -1 means no value */
    private int millis = NO_VALUE;

    /**
     * Sets the year value.
     *
     * @param   newYear  the new year value, in the range 0000 to 9999, or NO_VALUE to clear the
     *                   year field
     * @throws  IllegalArgumentException  if the supplied year value is not either <code>
     *                                    NO_VALUE</code> or in the range 0-9999.
     */
    public void setYear(final int newYear) {

        if ((newYear == NO_VALUE) || ((newYear >= 0) && (newYear <= 9999))) {
            this.year = newYear;
        } else {
            throw new IllegalArgumentException("year value out of range");
        }
    }

    /**
     * Gets the current year value.
     *
     * @return  the current year value, which may be <code>NO_VALUE</code>
     */
    public int getYear() {

        return this.year;
    }

    /**
     * Sets the month value.
     *
     * @param   newMonth  the new month value, in the range 1 to 12, or NO_VALUE to clear the month
     *                    field
     * @throws  IllegalArgumentException  if the supplied month value is not either <code>
     *                                    NO_VALUE</code> or in the range 1-12.
     */
    public void setMonth(final int newMonth) {

        if ((newMonth == NO_VALUE) || ((newMonth >= 1) && (newMonth <= 12))) {
            this.month = newMonth;
        } else {
            throw new IllegalArgumentException("month value out of range");
        }
    }

    /**
     * Gets the current month value.
     *
     * @return  the current month value, which may be <code>NO_VALUE</code>
     */
    public int getMonth() {

        return this.month;
    }

    /**
     * Sets the day value.
     *
     * @param   newDay  the new day value, or NO_VALUE to clear the day field
     * @throws  IllegalArgumentException  if the supplied day value is not either <code>
     *                                    NO_VALUE</code> or in the range 1-31.
     */
    public void setDay(final int newDay) {

        if ((newDay == NO_VALUE) || ((newDay >= 1) && (newDay <= 31))) {
            this.day = newDay;
        } else {
            throw new IllegalArgumentException("day value out of range");
        }
    }

    /**
     * Gets the current day value.
     *
     * @return  the current day value, which may be <code>NO_VALUE</code>
     */
    public int getDay() {

        return this.day;
    }

    /**
     * Sets the hour value.
     *
     * @param   newHour  the new hour value, or NO_VALUE to clear the hour field
     * @throws  IllegalArgumentException  if the supplied hour value is not either <code>
     *                                    NO_VALUE</code> or in the range 0-23.
     */
    public void setHour(final int newHour) {

        if ((newHour == NO_VALUE) || ((newHour >= 0) && (newHour <= 23))) {
            this.hour = newHour;
        } else {
            throw new IllegalArgumentException("hour value out of range");
        }
    }

    /**
     * Gets the current hour value.
     *
     * @return  the current hour value, which may be <code>NO_VALUE</code>
     */
    public int getHour() {

        return this.year;
    }

    /**
     * Sets the minute value.
     *
     * @param   newMinute  the new minute value, or NO_VALUE to clear the minute field
     * @throws  IllegalArgumentException  if the supplied minute value is not either <code>
     *                                    NO_VALUE</code> or in the range 0-59.
     */
    public void setMinute(final int newMinute) {

        if ((newMinute == NO_VALUE) || ((newMinute >= 0) && (newMinute <= 59))) {
            this.minute = newMinute;
        } else {
            throw new IllegalArgumentException("minute value out of range");
        }
    }

    /**
     * Gets the current minute value.
     *
     * @return  the current minute value, which may be <code>NO_VALUE</code>
     */
    public int getMinute() {

        return this.minute;
    }

    /**
     * Sets the second value.
     *
     * @param   newSecond  the new second value, or NO_VALUE to clear the second field
     * @throws  IllegalArgumentException  if the supplied second value is not either <code>
     *                                    NO_VALUE</code> or in the range 0-59.
     */
    public void setSecond(final int newSecond) {

        if ((newSecond == NO_VALUE) || ((newSecond >= 0) && (newSecond <= 59))) {
            this.second = newSecond;
        } else {
            throw new IllegalArgumentException("second value out of range");
        }
    }

    /**
     * Gets the current second value.
     *
     * @return  the current second value, which may be <code>NO_VALUE</code>
     */
    public int getSecond() {

        return this.second;
    }

    /**
     * Sets the milliseconds value.
     *
     * @param   newMillis  the new milliseconds value, or NO_VALUE to clear the milliseconds field
     * @throws  IllegalArgumentException  if the supplied milliseconds value is not either <code>
     *                                    NO_VALUE</code> or in the range 0-999.
     */
    public void setMillis(final int newMillis) {

        if ((newMillis == NO_VALUE) || ((newMillis >= 0) && (newMillis <= 999))) {
            this.millis = newMillis;
        } else {
            throw new IllegalArgumentException("year value out of range");
        }
    }

    /**
     * Gets the current milliseconds value.
     *
     * @return  the current milliseconds value, which may be <code>NO_VALUE</code>
     */
    public int getMillis() {

        return this.millis;
    }

    /**
     * Tests whether the date is completely set (that is, if the month, day, and year fields all
     * have values other than <code>No_VALUE</code>.
     *
     * @return  <code>true</code> if the date is completely set; <code>false</code> otherwise
     */
    public boolean isDateSet() {

        return (this.year != NO_VALUE) && (this.month != NO_VALUE) && (this.day != NO_VALUE);
    }

    /**
     * Tests whether the time is completely set (that is, if the hour, minute, and second fields
     * all have values other than <code>No_VALUE</code>.
     *
     * @return  <code>true</code> if the time is completely set; <code>false</code> otherwise
     */
    public boolean isTimeSet() {

        return (this.hour != NO_VALUE) && (this.minute != NO_VALUE) && (this.second != NO_VALUE);
    }

    /**
     * Generates the string representation of the local time. There are several permitted formats
     * for the <code>String</code>:
     *
     * <p>If neither date nor time is populated, returns 'never'.
     *
     * <p>If the date is complete, but time is not, the output will be a 10-character string in the
     * format 'MM/DD/YYYY'.
     *
     * <p>If the time is complete but the date is not, the output will be either an 8-character
     * string of format 'hh:mm:ss' (if milliseconds field is not set), or a 12-character string of
     * format 'hh:mm:ss.uuu' (if milliseconds field is set).
     *
     * <p>If date and time are both complete, the output will be either a 19-character string of
     * format 'MM/DD/YYYY hh:mm:ss' (if milliseconds field is not set), or a 23-character string of
     * format 'MM/DD/YYYY hh:mm:ss.uuu' (if milliseconds field is sets).
     *
     * @return  the string representation
     */
    @Override public String toString() {

        StringBuilder str;

        str = new StringBuilder(30);

        if (isDateSet()) {

            if (isTimeSet()) {
                appendDate(str);
                str.append(' ');
                appendTime(str);
            } else {
                appendDate(str);
            }
        } else if (isTimeSet()) {
            appendTime(str);
        } else {
            str.append("never");
        }

        return str.toString();
    }

    /**
     * Appends the date to a <code>StringBuilder</code>, in the format 'MM/DD/YYYY'.
     *
     * @param  str  the <code>StringBuilder</code> to which to append
     */
    private void appendDate(final StringBuilder str) {

        str.append(Integer.toString((this.day / 10) % 10));
        str.append(Integer.toString(this.day % 10));
        str.append('/');
        str.append(Integer.toString((this.month / 10) % 10));
        str.append(Integer.toString(this.month % 10));
        str.append('/');
        str.append(Integer.toString((this.year / 1000) % 10));
        str.append(Integer.toString((this.year / 100) % 10));
        str.append(Integer.toString((this.year / 10) % 10));
        str.append(Integer.toString(this.year % 10));
    }

    /**
     * Appends the time to a <code>StringBuilder</code>, in the format 'hh:mm:ss' or
     * 'hh:mm:ss.uuu'.
     *
     * @param  str  the <code>StringBuilder</code> to which to append
     */
    private void appendTime(final StringBuilder str) {

        str.append(Integer.toString((this.hour / 10) % 10));
        str.append(Integer.toString(this.hour % 10));
        str.append(':');
        str.append(Integer.toString((this.minute / 10) % 10));
        str.append(Integer.toString(this.minute % 10));
        str.append(':');
        str.append(Integer.toString((this.second / 10) % 10));
        str.append(Integer.toString(this.second % 10));

        if (this.millis != NO_VALUE) {
            str.append('.');
            str.append(Integer.toString((this.millis / 100) % 10));
            str.append(Integer.toString((this.millis / 10) % 10));
            str.append(Integer.toString(this.millis % 10));
        }
    }

    /**
     * Parses a <code>LocalTime</code> from a String representation.
     *
     * @param   str  the string to parse
     * @return  the parsed <code>LocalTime</code> object.
     * @throws  IllegalArgumentException  if the strung could not be parsed.
     */
    public static LocalTime parse(final String str) throws IllegalArgumentException {

        LocalTime obj;

        if ("never".equals(str)) {
            obj = new LocalTime();
        } else {

            switch (str.length()) {

            case 8: // hh:mm:ss
                if ((str.charAt(2) == ':') && (str.charAt(5) == ':')) {
                    obj = new LocalTime();
                    parseHMS(obj, str, 0);
                } else {
                    throw new IllegalArgumentException(ERR);
                }

                break;

            case 10: // MM/DD/YYYY
                if ((str.charAt(2) == '/') && (str.charAt(5) == '/')) {
                    obj = new LocalTime();
                    parseMDY(obj, str, 0);
                } else {
                    throw new IllegalArgumentException(ERR);
                }

                break;

            case 12: // hh:mm:ss.uuuu
                if ((str.charAt(2) == ':') && (str.charAt(5) == ':') && (str.charAt(8) == '.')) {
                    obj = new LocalTime();
                    parseHMSU(obj, str, 0);
                } else {
                    throw new IllegalArgumentException(ERR);
                }

                break;

            case 19: // MM/DD/YYYY hh:mm:ss
                if ((str.charAt(2) == '/') && (str.charAt(5) == '/') && (str.charAt(10) == ' ')
                        && (str.charAt(13) == ':') && (str.charAt(16) == ':')) {
                    obj = new LocalTime();
                    parseMDY(obj, str, 0);
                    parseHMS(obj, str, 11);
                } else {
                    throw new IllegalArgumentException(ERR);
                }

                break;

            case 23: // MM/DD/YYYY hh:mm:ss.uuuu
                if ((str.charAt(2) == '/') && (str.charAt(5) == '/') && (str.charAt(10) == ' ')
                        && (str.charAt(13) == ':') && (str.charAt(16) == ':')
                        && (str.charAt(19) == '.')) {
                    obj = new LocalTime();
                    parseMDY(obj, str, 0);
                    parseHMSU(obj, str, 11);
                } else {
                    throw new IllegalArgumentException(ERR);
                }

                break;

            default:
                throw new IllegalArgumentException(ERR);
            }
        }

        return obj;
    }

    /**
     * Parses an hour-minute-second value from a string of the format 'hh:mm:ss'.
     *
     * @param   obj    the object into which to place parsed values
     * @param   str    the string to parse
     * @param   index  the index of the start of the value in the string
     * @throws  IllegalArgumentException  if the value cannot be parsed
     */
    private static void parseHMS(final LocalTime obj, final String str, final int index)
        throws IllegalArgumentException {

        try {
            obj.setHour(Integer.parseInt(str.substring(index, index + 2)));
            obj.setMinute(Integer.parseInt(str.substring(index + 3, index + 5)));
            obj.setSecond(Integer.parseInt(str.substring(index + 6, index + 8)));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(ERR, e);
        }
    }

    /**
     * Parses an hour-minute-second-microsecond value from a string of the format 'hh:mm:ss.uuuu'.
     *
     * @param   obj    the object into which to place parsed values
     * @param   str    the string to parse
     * @param   index  the index of the start of the value in the string
     * @throws  IllegalArgumentException  if the value cannot be parsed
     */
    private static void parseHMSU(final LocalTime obj, final String str, final int index)
        throws IllegalArgumentException {

        try {
            obj.setHour(Integer.parseInt(str.substring(index, index + 2)));
            obj.setMinute(Integer.parseInt(str.substring(index + 3, index + 5)));
            obj.setSecond(Integer.parseInt(str.substring(index + 6, index + 8)));
            obj.setMillis(Integer.parseInt(str.substring(index + 9, index + 12)));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(ERR, e);
        }
    }

    /**
     * Parses an month-day-year value from a string of the format 'MM/DD/YYYY'.
     *
     * @param   obj    the object into which to place parsed values
     * @param   str    the string to parse
     * @param   index  the index of the start of the value in the string
     * @throws  IllegalArgumentException  if the value cannot be parsed
     */
    private static void parseMDY(final LocalTime obj, final String str, final int index)
        throws IllegalArgumentException {

        try {
            obj.setMonth(Integer.parseInt(str.substring(index, index + 2)));
            obj.setDay(Integer.parseInt(str.substring(index + 3, index + 5)));
            obj.setYear(Integer.parseInt(str.substring(index + 6, index + 10)));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(ERR, e);
        }
    }
}
