/*
 * $Id$
 *
 * Copyright 2006, The jCoderZ.org Project. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *    * Redistributions of source code must retain the above copyright
 *      notice, this list of conditions and the following disclaimer.
 *    * Redistributions in binary form must reproduce the above
 *      copyright notice, this list of conditions and the following
 *      disclaimer in the documentation and/or other materials
 *      provided with the distribution.
 *    * Neither the name of the jCoderZ.org Project nor the names of
 *      its contributors may be used to endorse or promote products
 *      derived from this software without specific prior written
 *      permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS
 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
 * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
 * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.jcoderz.commons.types;

import java.io.Serializable;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.TimeZone;

import org.jcoderz.commons.ArgumentMalformedException;
import org.jcoderz.commons.util.Assert;


/**
 * Encapsulates the Year Month type.
 * Instances of this class are immutable.
 * Years before 1 are not fully supported and might result in wrong string
 * representations. Not parseable time zones might be ignored.
 * @author Andreas Mandel
 */
public final class YearMonth
      implements Serializable
{
   /** The name of this type. */
   public static final String TYPE_NAME = "YearMonth";

   /** Minimum length for the year. */
   public static final int MINIMUM_NUMBER_OF_YEAR_DIGITS = 4;

   /** Fixed length for the month. */
   public static final int MONTH_LENGTH = 2;

   private static final int TWO_DIGIT_MONTH = 10;

   /** The <code>serialVersionUID</code>. */
   private static final long serialVersionUID = 1L;

   private final int mYear;
   /** Month counting from 1 (January) to 12 (December). */
   private final int mMonth;

   // Lazy init members
   private transient Date mEndDate;
   private transient Date mStartDate;
   private transient int mHashCode;
   private transient String mString;
   private transient Period mPeriod;

   /**
    *
    */
   private YearMonth (int year, int month)
   {
      if (1 > month || month > Date.MONTH_PER_YEAR)
      {
         throw new ArgumentMalformedException(TYPE_NAME, String.valueOf(month),
               "Month must be between 1 and 12.");
      }
      if (year == 0)
      {
         throw new ArgumentMalformedException(TYPE_NAME, String.valueOf(year),
               "Value of Year must not be 0.");
      }
      mYear = year;
      mMonth = month;
   }

   /**
    * Parses a valid XML representation of the gYearMonth type.
    * The format is <tt>CCYY-MM</tt>.
    * @param str the string representing the year month.
    * @return a YearMonth object representing the given year month.
    */
   public static YearMonth fromString (String str)
   {
      Assert.notNull(str, TYPE_NAME);
      // find separating '-' char.
      final int minusPos = str.indexOf('-', 1);
      if (minusPos == -1)
      {
         throw new ArgumentMalformedException(TYPE_NAME, str,
               "MonthYear type must contain a '-' character. (CCYY-MM)");
      }
      if ((minusPos + 1) < MINIMUM_NUMBER_OF_YEAR_DIGITS)
      {
         throw new ArgumentMalformedException(TYPE_NAME, str,
               "MonthYear type have at least "
               + MINIMUM_NUMBER_OF_YEAR_DIGITS + " digits in front of the '-' "
               + "character. (CCYY-MM)");
      }
      final int year;
      try
      {
         year = Integer.parseInt(str.substring(0, minusPos));
      }
      catch (NumberFormatException ex)
      {
         throw new ArgumentMalformedException(TYPE_NAME, str,
               "Failed to parse year. (CCYY-MM)", ex);
      }
      if (str.length() - minusPos <= MONTH_LENGTH)
      {
         throw new ArgumentMalformedException(TYPE_NAME, str,
               "Month must be 2 digits long. (CCYY-MM)");
      }
      final int month;
      try
      {
         month = Integer.parseInt(str.substring(minusPos + 1,
               minusPos + 1 + MONTH_LENGTH));
      }
      catch (NumberFormatException ex)
      {
         throw new ArgumentMalformedException(TYPE_NAME, str,
               "Failed to parse month. (CCYY-MM)", ex);
      }
      if (str.length() > minusPos + 1 + MONTH_LENGTH)
      {
         // Check timezone
         final String tz = str.substring(minusPos + 1 + MONTH_LENGTH);
         final TimeZone timeZone = TimeZone.getTimeZone(tz);
         if (timeZone.getRawOffset() != 0)
         {
            throw new ArgumentMalformedException(TYPE_NAME, str,
                  "Only UTC is supported as time zone, not '" + tz + "'.");
         }
      }
      return new YearMonth(year, month);
   }

   /**
    * Returns the valid date that represents the beginning of the
    * month year type.
    * The date in time is the first second in the month year.
    * @return a date denoting the first second in the month year.
    */
   public Date toStartDate ()
   {
      if (mStartDate == null)
      {
         final Calendar cal = Calendar.getInstance(Date.TIME_ZONE);
         cal.setLenient(false);
         cal.clear();
         if (getYear() > 0)
         {
            cal.set(getYear(), getMonth() - 1, 1);
         }
         else
         {
            cal.set(-getYear(), getMonth() - 1, 1);
            cal.set(Calendar.ERA, GregorianCalendar.BC);
         }
         cal.set(Calendar.DAY_OF_MONTH,
            cal.getActualMinimum(Calendar.DAY_OF_MONTH));
         cal.set(Calendar.HOUR_OF_DAY,
            cal.getActualMinimum(Calendar.HOUR_OF_DAY));
         cal.set(Calendar.MINUTE, cal.getActualMinimum(Calendar.MINUTE));
         cal.set(Calendar.SECOND, cal.getActualMinimum(Calendar.SECOND));
         cal.set(Calendar.MILLISECOND,
               cal.getActualMinimum(Calendar.MILLISECOND));
         mStartDate = new Date(cal.getTimeInMillis());
      }
      return mStartDate;
   }

   /**
    * Returns the valid date that represents the end of the
    * month year type.
    * The date in time is the first second in the month year.
    * @return a date denoting the first second in the month year.
    */
   public Date toEndDate ()
   {
      if (mEndDate == null)
      {
         final Calendar cal = Calendar.getInstance(Date.TIME_ZONE);
         cal.setLenient(false);
         cal.clear();
         if (getYear() > 0)
         {
            cal.set(getYear(), getMonth() - 1, 1);
         }
         else
         {
            cal.set(-getYear(), getMonth() - 1, 1);
            cal.set(Calendar.ERA, GregorianCalendar.BC);
         }
         cal.set(Calendar.DAY_OF_MONTH,
            cal.getActualMaximum(Calendar.DAY_OF_MONTH));
         cal.set(Calendar.HOUR_OF_DAY,
            cal.getActualMaximum(Calendar.HOUR_OF_DAY));
         cal.set(Calendar.MINUTE, cal.getActualMaximum(Calendar.MINUTE));
         cal.set(Calendar.SECOND, cal.getActualMaximum(Calendar.SECOND));
         cal.set(Calendar.MILLISECOND,
               cal.getActualMaximum(Calendar.MILLISECOND));
         mEndDate = new Date(cal.getTimeInMillis());
      }
      return mEndDate;
   }

   /**
    * Returns this as period from start date to end date of this
    * YearMonth.
    * @return a period representing the time period of this year month.
    */
   public Period toPeriod ()
   {
      if (mPeriod == null)
      {
         mPeriod = Period.createPeriod(toStartDate(), toEndDate());
      }
      return mPeriod;
   }

   /**
    * Returns the month counting from 1 (January) to 12 (December).
    * @return The month counting from 1 (January) to 12 (December).
    */
   public int getMonth ()
   {
      return mMonth;
   }
   /**
    * @return Returns the year.
    */
   public int getYear ()
   {
      return mYear;
   }

   /**
    * Returns true if the given date is within this year/month.
    * @param date the point in time to check.
    * @return true if the given date is within this year/month.
    */
   public boolean isWithin (Date date)
   {
      final long current = date.getTime();
      return toStartDate().getTime() <= current
            && current <= toEndDate().getTime();
   }

   /** {@inheritDoc} */
   public String toString ()
   {
      if (mString == null)
      {
         String year;
         if (mYear >= 0)
         {
            year = Integer.toString(mYear);
            if (year.length() < MINIMUM_NUMBER_OF_YEAR_DIGITS)
            {
               year = "0000".substring(year.length()) + year;
            }
         }
         else
         {
            year = Integer.toString(-mYear);
            if (year.length() < MINIMUM_NUMBER_OF_YEAR_DIGITS)
            {
               year = "0000".substring(year.length()) + year;
            }
            year = "-" + year;
         }
         if (mMonth < TWO_DIGIT_MONTH)
         {
            mString = year + "-0" + Integer.toString(mMonth) + 'Z';
         }
         else
         {
            mString = year + '-' + Integer.toString(mMonth) + 'Z';
         }
      }
      return mString;
   }

   /** {@inheritDoc} */
   public int hashCode ()
   {
      if (mHashCode == 0)
      {
         mHashCode = mYear * Date.MONTH_PER_YEAR + mMonth;
      }
      return mHashCode;
   }

   /** {@inheritDoc} */
   public boolean equals (Object o)
   {
      final boolean result;
      if (o instanceof YearMonth)
      {
         final YearMonth other =  (YearMonth) o;
         result = other.mMonth == mMonth && other.mYear == mYear;
      }
      else
      {
         result = false;
      }
      return result;
   }
}

