# DateOps.java

    /*
    ** Module   : DateOps.java
    ** Abstract : Progress 4GL compatible date operations.
    **
    ** Copyright (c) 2013-2022, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
    ** 001 GES 20130121 Created initial version.
    ** 002 OM  20130531 Added missing implementation.
    ** 003 CA  20191211 Do not use datetimetz when adding the millis, but a duplicate of the existing instance.
    ** 004 IAS 20211015 Fixed potential ClassCastException in the addInterval method
    ** 005 CA  20220622 Fixed INTERVAL and ADD-INTERVAL - they weren't addressing the timezone in a datetimetz
    **                  properly.
    **     OM  20220622 Further improve interval operations.
    */

    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program is distributed in the hope that it will be useful,
    ** but WITHOUT ANY WARRANTY; without even the implied warranty of
    ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ** GNU Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.util;

    import java.util.*;

    /**
     * A class that provides Progress 4GL compatible date functions. This class
     * provides all functionality as statics and has no instance data.
     * <p>
     * The following is the mapping of Progress language features to the
     * corresponding feature in this class:
     * <p>
     * <pre>
     * add-interval    function                 {@link #addInterval}
     * interval        function                 {@link #interval}
     * </pre>
     * <p>
     * The above methods all return an instance of wrapper classes which allow
     * the Progress unknown value to be properly handled.  Overloaded versions of
     * the methods exist which handle different combinations of primative type
     * parameters, allowing the resulting code to be implemented without
     * unnecessary construction of the wrapper types for all parameters.
     */
    public final class DateOps
    {
       /**
        * Adds or subtracts a time amount to/from an instance of a date class
        * and returns the new date instance.  This can be used with {@link date}
        * (or any subclass) and the same type will be returned.
        *
        * @param    dat
        *           The date to add to or subtract from.
        * @param    amount
        *           If positive, the amount to add.  If negative, the amount to subtract.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks', 'days', 'hours',
        *           'minutes', 'seconds' or 'milliseconds'. Any case is accepted and it may be
        *           specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is unknown.
        */
       @SuppressWarnings("unchecked") // duplicate(), instantiateUnknown() and instantiateDefault() are safe
       public static <T extends date> T addInterval(T dat, long amount, String unit)
       {
          int calField = getGregorianField(unit);
          if (calField < 0)
          {
             // parameter validation
             ErrorManager.recordOrThrowError(11262,
                   "Third parameter for the INTERVAL/ADD-INTERVAL function must be unit of measure");
             return (T) dat.instantiateUnknown();
          }

          // validation, trivial case: no computation needed
          if (amount == 0)
          {
             return (T) dat.duplicate();
          }

          // validation, second trivial case: no computation needed
          if (dat.isUnknown())
          {
             return (T) dat.instantiateUnknown();
          }

          // simple case: add a fixed amount of time that will be converted to milliseconds
          long delta = getIntervalInMillis(amount, unit);
          if (delta != 0)
          {
             datetime inter = dat instanceof datetime ? (datetime) dat.duplicate() : new datetime(dat);
             inter = datetime.plusMillis(inter, delta);

             T ret = (T) dat.duplicate();
             ret.assign(inter);
             return ret;
          }

          // second simple case: add a variable amount of days (years/months)
          Calendar jcal = GregorianCalendar.getInstance();
          jcal.setTime(dat.dateValue());
          jcal.add(calField, (int) amount);

          T ret = (T) dat.duplicate();
          date inter2 = new date(jcal.getTime());
          ret.setDayNumber(inter2.intValue());
          return ret;

          // no other cases
       }

       /**
        * Adds or subtracts a time amount to/from an instance of a date class
        * and returns the new date instance.  This can be used with {@link date}
        * (or any subclass) and the same type will be returned.
        *
        * @param    dat
        *           The date to add to or subtract from.
        * @param    amount
        *           If positive, the amount to add.  If negative, the amount to
        *           subtract.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks',
        *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any
        *           case is accepted and it may be specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is
        *           unknown.
        */
       @SuppressWarnings("unchecked")
       public static <T extends date> T addInterval(T dat, NumberType amount, String unit)
       {
          if (dat.isUnknown() || amount.isUnknown())
          {
             return (T) dat.instantiateUnknown();
          }

          return addInterval(dat, amount.longValue(), unit);
       }

       /**
        * Adds or subtracts a time amount to/from an instance of a date class
        * and returns the new date instance.  This can be used with {@link date}
        * (or any subclass) and the same type will be returned.
        *
        * @param    dat
        *           The date to add to or subtract from.
        * @param    amount
        *           If positive, the amount to add.  If negative, the amount to
        *           subtract.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks',
        *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any
        *           case is accepted and it may be specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is
        *           unknown.
        */
       @SuppressWarnings("unchecked")
       public static <T extends date> T addInterval(T dat, long amount, character unit)
       {
          if (dat.isUnknown() || unit.isUnknown())
          {
             return (T) dat.instantiateUnknown();
          }

          return addInterval(dat, amount, unit.toStringMessage());
       }

       /**
        * Adds or subtracts a time amount to/from an instance of a date class
        * and returns the new date instance.  This can be used with {@link date}
        * (or any subclass) and the same type will be returned.
        *
        * @param    dat
        *           The date to add to or subtract from.
        * @param    amount
        *           If positive, the amount to add.  If negative, the amount to
        *           subtract.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks',
        *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any
        *           case is accepted and it may be specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is
        *           unknown.
        */
       @SuppressWarnings("unchecked")
       public static <T extends date> T addInterval(T dat, NumberType amount, character  unit)
       {
          if (dat.isUnknown() || amount.isUnknown() || unit.isUnknown())
          {
             return (T) dat.instantiateUnknown();
          }

          return addInterval(dat, amount.longValue(), unit.toStringMessage());
       }

       /**
        * Calculate the time interval between two date values and return it in user-specified units.
        *
        * @param    d1
        *           The first date value.
        * @param    d2
        *           The second date value.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks',
        *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any
        *           case is accepted and it may be specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is unknown.
        */
       public static int64 interval(date d1, date d2, String unit)
       {
          int calField = getGregorianField(unit);
          if (calField < 0)
          {
             // parameter validation
             ErrorManager.recordOrThrowError(11262,
                  "Third parameter for the INTERVAL/ADD-INTERVAL function must be unit of measure");
             return new int64();
          }

          if (d1.isUnknown() || d2.isUnknown())
          {
             return new int64();
          }

          // adjust both to UTC (to have a common denominator with the other date instant)
          datetime d1utc = d1.utc();
          datetime d2utc = d2.utc();

          unit = unit.toLowerCase();

          // day difference between dates:
          long dd = d1utc.longValue() - d2utc.longValue();
          // compute time difference between dates:
          long dt = d1utc.getTime() - d2utc.getTime();

          // adjust dd & dt to have the same sign
          if (dd < 0)
          {
             if (dt > 0)
             {
                ++dd;
                dt -= date.MILLIS_PER_DAY;
             }
             // else they are both negative
          }
          else if (dd > 0)
          {
             if (dt < 0)
             {
                --dd;
                dt += date.MILLIS_PER_DAY;
             }
             // else they are both positive
          }
          // else dd is 0 so only compute using dt

          long multiplier = getIntervalInMillis(1, unit);
          if (multiplier > 0)
          {
             // we know the exact unit size
             return new int64(((dd * date.MILLIS_PER_DAY) + dt) / multiplier);
          }

          // else unit is 'year' or 'month' or.. other invalid value
          boolean umYear = "years".equals(unit) || "year".equals(unit);
          boolean umMonth = "month".equals(unit) || "months".equals(unit);
          if (!umYear && !umMonth)
          {
             // unrecognized unit, returning unknown value
             return new int64();
          }

          // try to order the dates so that they are in REVERSE chronological order
          // as date/datetime and datetime-tz are not directly comparable; we do it ad-hoc:
          boolean rev = false;
          if (d1utc.longValue() < d2utc.longValue())
          {
             // different days, d1 is less, they will be switched
             rev = true;
          }
          else if (d1utc.longValue() == d2utc.longValue())
          {
             // same day, check the time
             if (d1utc.getTime() < d2utc.getTime())
             {
                // if d1 is early, the values will be switched
                rev = true;
             }
          }
          if (rev)
          {
             datetime tmp = d1utc;
             d1utc  = d2utc;
             d2utc  = tmp;
          }
          // now d1 > d2 so the difference d1 - d2 is always positive.

          int y1 = d1utc.getYear();
          int y2 = d2utc.getYear();
          int m1 = d1utc.getMonth();
          int m2 = d2utc.getMonth();
          int z1 = d1utc.getDay();
          int z2 = d2utc.getDay();
          long t1 = d1utc.getTime();
          long t2 = d2utc.getTime();

          if (umYear)
          {
             // dy is always positive as the date were eventually reordered (rev is true in this case)
             int dy = y1 - y2;
             if (m1 > m2)
             {
                return new int64(rev ? -dy : dy);
             }
             else if (m1 == m2)
             {
                if (z1 > z2)
                {
                   return new int64(rev ? -dy : dy);
                }
                else if (z1 == z2)
                {
                   if (t1 >= t2)
                   {
                      return new int64(rev ? -dy : dy);
                   }
                }
             }
             // there is not yet a full year
             return new int64(rev ? 1 - dy : dy - 1);
          }
          else // umMonth = true
          {
             // dm is always positive as the date were eventually reordered (rev is true in this case)
             long dm = (y1 - y2) * 12L + (m1 - m2);
             if (z1 > z2)
             {
                return new int64(rev ? -dm : dm);
             }
             else if (z1 == z2)
             {
                if (t1 >= t2)
                {
                   return new int64(rev ? -dm : dm);
                }
             }
             // there is not yet a full month
             return new int64(rev ? 1 - dm : dm - 1);
          }
       }

       /**
        * Calculate the time interval between two date values and return it
        * in user-specified units.
        *
        * @param    d1
        *           The first date value.
        * @param    d2
        *           The second date value.
        * @param    unit
        *           One of the following time units: 'years', 'months', 'weeks',
        *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any
        *           case is accepted and it may be specified as singular.
        *
        * @return   The computed value or <code>unknown</code> if any input is
        *           unknown.
        */
       public static int64 interval(date d1, date d2, character unit)
       {
          if (unit.isUnknown())
          {
             return new int64();
          }

          return interval(d1, d2, unit.toStringMessage());
       }

       /**
        * Computes and returns an interval of a fixed size time unit.
        *
        * @param    amount
        *           The amount of units.
        * @param    unit
        *           One of the following time units: 'weeks', 'days', 'hours', 'minutes', 'seconds'
        *           or 'milliseconds'. Any case is accepted and it may be specified as singular.
        *
        * @return   The milliseconds in terms of the given unit.
        */
       private static long getIntervalInMillis(long amount, String unit)
       {
          unit = unit.toLowerCase();

          if ("milliseconds".equals(unit) || "millisecond".equals(unit))
          {
             return amount;
          }

          amount *= 1000;
          if ("seconds".equals(unit) || "second".equals(unit))
          {
             return amount;
          }

          amount *= 60;
          if ("minutes".equals(unit) || "minute".equals(unit))
          {
             return amount;
          }

          amount *= 60;
          if ("hours".equals(unit) || "hour".equals(unit))
          {
             return amount;
          }

          amount *= 24;
          if ("days".equals(unit) || "day".equals(unit))
          {
             return amount;
          }

          amount *= 7;
          if ("weeks".equals(unit) || "week".equals(unit))
          {
             return amount;
          }

          return 0;
       }

       /**
        * Maps Progress calendar fields to Java Calendar fields.
        *
        * @param   pUnit
        *          The name of time unit from Progress.
        *
        * @return  The corresponding Calendar field Id from java.util.Calendar.
        */
       private static int getGregorianField(String pUnit)
       {
          // to simplify things, convert to lowercase
          pUnit = pUnit.toLowerCase();

          if ("milliseconds".equals(pUnit) || "millisecond".equals(pUnit))
          {
             return Calendar.MILLISECOND;
          }

          if ("seconds".equals(pUnit) || "second".equals(pUnit))
          {
             return Calendar.SECOND;
          }

          if ("minutes".equals(pUnit) || "minute".equals(pUnit))
          {
             return Calendar.MINUTE;
          }

          if ("hours".equals(pUnit) || "hour".equals(pUnit))
          {
             return Calendar.HOUR_OF_DAY;
          }

          if ("days".equals(pUnit) || "day".equals(pUnit))
          {
             return Calendar.DATE;
          }

          if ("weeks".equals(pUnit) || "week".equals(pUnit))
          {
             return Calendar.WEEK_OF_YEAR;
          }

          if ("months".equals(pUnit) || "month".equals(pUnit))
          {
             return Calendar.MONTH;
          }

          if ("years".equals(pUnit) || "year".equals(pUnit))
          {
             return Calendar.YEAR;
          }

          return -1;
       }

       /**
        * Compares two date-related values.
        * The arguments can be any date-related values. If needed they are converted to local time
        * before doing actual comparison.
        *
        * @param   d1
        *          The first date to compare.
        * @param   d2
        *          The second date to compare.
        *
        * @return  0 if operands are equals, -1 if fist is less and 1 if first is greater than second.
        */
       public static int compare(date d1, date d2)
       {
          boolean u1 = d1.isUnknown();
          boolean u2 = d2.isUnknown();

          if (u1 && u2)
          {
             // two unknown are equals
             return 0;
          }
          if (u1 || u2)
          {
             // only of is unknown, the other is not
             return -1;
          }

          datetime dt1 = d1.utc();
          datetime dt2 = d2.utc();
          long d1AbsTime = dt1.longValue() * date.MILLIS_PER_DAY + dt1.getTime();
          long d2AbsTime = dt2.longValue() * date.MILLIS_PER_DAY + dt2.getTime();

          if (d1AbsTime < d2AbsTime)
          {
             return -1;
          }
          else if (d1AbsTime > d2AbsTime)
          {
             return +1;
          }

          // else they are equals:
          return 0;
       }
    }