/*
 **** BEGIN LICENSE BLOCK *****
 * Version: EPL 1.0/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Eclipse Public
 * License Version 1.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.eclipse.org/legal/epl-v20.html
 *
 * Software distributed under the License is distributed on an "AS
 * IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * rights and limitations under the License.
 *
 * Copyright (C) 2018 The JRuby Team
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either of the GNU General Public License Version 2 or later (the "GPL"),
 * or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the EPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the EPL, the GPL or the LGPL.
 ***** END LICENSE BLOCK *****/

package org.jruby.ext.date;

import org.jcodings.specific.USASCIIEncoding;
import org.joda.time.Chronology;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.chrono.GJChronology;

import org.jruby.Ruby;
import org.jruby.RubyArray;
import org.jruby.RubyBignum;
import org.jruby.RubyClass;
import org.jruby.RubyFixnum;
import org.jruby.RubyFloat;
import org.jruby.RubyInteger;
import org.jruby.RubyNumeric;
import org.jruby.RubyRational;
import org.jruby.RubyString;
import org.jruby.RubyTime;
import org.jruby.anno.JRubyClass;
import org.jruby.anno.JRubyMethod;
import org.jruby.runtime.Arity;
import org.jruby.runtime.ThreadContext;
import org.jruby.runtime.builtin.IRubyObject;
import org.jruby.util.ByteList;

import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.time.*;

import static org.jruby.api.Access.timeClass;
import static org.jruby.api.Convert.asFixnum;
import static org.jruby.api.Convert.toInt;
import static org.jruby.api.Convert.toLong;
import static org.jruby.api.Define.defineClass;

/**
 * JRuby's <code>DateTime</code> implementation - 'native' parts.
 * In MRI, since 2.x, all of date.rb has been moved to native (C) code.
 *
 * NOTE: There's still date.rb, where this gets bootstrapped from.
 *
 * @see RubyDate
 * @since 9.2
 *
 * @author kares
 */
@JRubyClass(name = "DateTime")
public class RubyDateTime extends RubyDate {

    static void createDateTimeClass(ThreadContext context, RubyClass Date) {
        defineClass(context, "DateTime", Date, RubyDateTime::new).
                reifiedClass(RubyDateTime.class).
                defineMethods(context, RubyDateTime.class);
    }

    protected RubyDateTime(Ruby runtime, RubyClass klass) {
        this(runtime, klass, defaultDateTime);
    }

    public RubyDateTime(Ruby runtime, RubyClass klass, DateTime dt) {
        super(runtime, klass, dt);

        this.off = dt.getZone().getOffset(dt.getMillis()) / 1000;
    }

    @Deprecated(since = "10.0.0.0")
    public RubyDateTime(Ruby runtime, DateTime dt) {
        this(runtime, getDateTime(runtime.getCurrentContext()), dt);
    }

    public RubyDateTime(Ruby runtime, long millis, Chronology chronology) {
        super(runtime, getDateTime(runtime.getCurrentContext()), new DateTime(millis, chronology));
    }

    RubyDateTime(ThreadContext context, RubyClass klass, IRubyObject ajd, int off, long start) {
        super(context, klass, ajd, off, start);
    }

    private RubyDateTime(ThreadContext context, RubyClass klass, IRubyObject ajd, long[] rest, int off, long start) {
        super(context, klass, ajd, rest, off, start);
    }

    private RubyDateTime(Ruby runtime, RubyClass klass, DateTime dt, int off) {
        super(runtime, klass);

        this.dt = dt;
        this.off = off;
    }

    RubyDateTime(Ruby runtime, RubyClass klass, DateTime dt, int off, long start) {
        super(runtime, klass);

        this.dt = dt;
        this.off = off; this.start = start;
    }

    RubyDateTime(Ruby runtime, RubyClass klass, DateTime dt, int off, long start, long subMillisNum, long subMillisDen) {
        super(runtime, klass);

        this.dt = dt;
        this.off = off; this.start = start;
        this.subMillisNum = subMillisNum; this.subMillisDen = subMillisDen;
    }

    RubyDateTime(ThreadContext context, RubyClass klass, IRubyObject ajd, Chronology chronology, int off) {
        super(context, klass, ajd, chronology, off);
    }

    @Override
    RubyDate newInstance(final ThreadContext context, final DateTime dt, int off, long start, long subNum, long subDen) {
        return new RubyDateTime(context.runtime, getMetaClass(), dt, off, start, subNum, subDen);
    }

    /**
     # Create a new DateTime object corresponding to the specified
     # Civil Date and hour +h+, minute +min+, second +s+.
     #
     # The 24-hour clock is used.  Negative values of +h+, +min+, and
     # +sec+ are treating as counting backwards from the end of the
     # next larger unit (e.g. a +min+ of -2 is treated as 58).  No
     # wraparound is performed.  If an invalid time portion is specified,
     # an ArgumentError is raised.
     #
     # +of+ is the offset from UTC as a fraction of a day (defaults to 0).
     # +sg+ specifies the Day of Calendar Reform.
     #
     # +y+ defaults to -4712, +m+ to 1, and +d+ to 1; this is Julian Day
     # Number day 0.  The time values default to 0.
     **/
    // DateTime.civil([year=-4712[, month=1[, mday=1[, hour=0[, minute=0[, second=0[, offset=0[, start=Date::ITALY]]]]]]]])
    // DateTime.new([year=-4712[, month=1[, mday=1[, hour=0[, minute=0[, second=0[, offset=0[, start=Date::ITALY]]]]]]]])

    @JRubyMethod(name = "civil", alias = "new", meta = true)
    public static RubyDateTime civil(ThreadContext context, IRubyObject self) {
        return new RubyDateTime(context.runtime, (RubyClass) self, defaultDateTime, 0);
    }

    @JRubyMethod(name = "civil", alias = "new", meta = true)
    public static RubyDateTime civil(ThreadContext context, IRubyObject self, IRubyObject year) {
        return new RubyDateTime(context.runtime, (RubyClass) self, civilImpl(context, year), 0);
    }

    @JRubyMethod(name = "civil", alias = "new", meta = true)
    public static RubyDateTime civil(ThreadContext context, IRubyObject self, IRubyObject year, IRubyObject month) {
        return new RubyDateTime(context.runtime, (RubyClass) self, civilImpl(context, year, month), 0);
    }

    @JRubyMethod(name = "civil", alias = "new", meta = true, optional = 8, checkArity = false)
    public static RubyDateTime civil(ThreadContext context, IRubyObject self, IRubyObject[] args) {
        // year=-4712, month=1, mday=1,
        //  hour=0, minute=0, second=0, offset=0, start=Date::ITALY
        int argc = Arity.checkArgumentCount(context, args, 0, 8);

        int hour = 0, minute = 0, second = 0; long millis = 0; long subMillisNum = 0, subMillisDen = 1;
        int off = 0; long sg = ITALY;

        if (argc == 8) sg = val2sg(context, args[7]);
        if (argc >= 7) off = val2off(context, args[6]);

        final int year = (sg > 0) ? getYear(context, args[0]) : toInt(context, args[0]);
        final int month = getMonth(context, args[1]);
        final long[] rest = new long[] { 0, 1 };
        final int day = (int) getDay(context, args[2], rest);

        if (argc >= 4 || rest[0] != 0) {
            hour = getHour(context, argc >= 4 ? args[3] : asFixnum(context, 0), rest);
        }

        if (argc >= 5 || rest[0] != 0) {
            minute = getMinute(context, argc >= 5 ? args[4] : asFixnum(context, 0), rest);
        }

        if (argc >= 6 || rest[0] != 0) {
            IRubyObject sec = argc >= 6 ? args[5] : asFixnum(context, 0);
            second = getSecond(context, sec, rest);
            final long r0 = rest[0], r1 = rest[1];
            if (r0 != 0) {
                millis = ( 1000 * r0 ) / r1;
                subMillisNum = ((1000 * r0) - (millis * r1)); subMillisDen = r1;
            }
        }

        if (hour == 24 && (minute != 0 || second != 0 || millis != 0)) {
            throw newDateError(context, "invalid date");
        }

        final Chronology chronology = getChronology(context, sg, off);
        DateTime dt = civilDate(context, year, month, day, chronology); // hour: 0, minute: 0, second: 0

        try {
            long ms = dt.getMillis();
            ms = chronology.hourOfDay().set(ms, hour == 24 ? 0 : hour);
            ms = chronology.minuteOfHour().set(ms, minute);
            ms = chronology.secondOfMinute().set(ms, second);
            dt = dt.withMillis(ms + millis);
            if (hour == 24) dt = dt.plusDays(1);
        }
        catch (IllegalArgumentException ex) {
            debug(context, "invalid date", ex);
            throw newDateError(context, "invalid date");
        }

        return (RubyDateTime) new RubyDateTime(context.runtime, (RubyClass) self, dt, off, sg, subMillisNum, subMillisDen).normalizeSubMillis();
    }

    static long getDay(ThreadContext context, IRubyObject day, final long[] rest) {
        long d = toLong(context, day);

        if (!(day instanceof RubyInteger) && day instanceof RubyNumeric daynum) { // Rational|Float
            RubyRational rat = daynum.convertToRational(context);
            if (rat.getNumerator() instanceof RubyBignum || rat.getDenominator() instanceof RubyBignum) {
                calcBigIntDayRest(context, rat, d, rest);
            } else {
                long num = rat.getNumerator().asLong(context);
                long den = rat.getDenominator().asLong(context);
                rest[0] = num - d * den; rest[1] = den;
            }
        }

        return d;
    }

    private static void calcBigIntDayRest(ThreadContext context, final RubyRational day, final long d, final long[] rest) {
        BigInteger num = day.getNumerator().asBigInteger(context);
        BigInteger den = day.getDenominator().asBigInteger(context);
        BigInteger r0 = num.subtract(den.multiply(BigInteger.valueOf(d)));
        BigInteger r1 = den;
        BigInteger gcd = r0.gcd(r1);
        r0 = r0.divide(gcd);
        r1 = r1.divide(gcd);
        try {
            rest[0] = r0.longValueExact();
            rest[1] = r1.longValueExact();
        } catch (ArithmeticException e) {
            BigDecimal r = new BigDecimal(r0).divide(new BigDecimal(r1), 18, RoundingMode.HALF_UP);
            r = r.setScale(18, RoundingMode.HALF_UP);
            rest[0] = r.unscaledValue().longValue();
            rest[1] = (long) Math.pow(10, r.scale());
        }
    }

    static int getHour(ThreadContext context, IRubyObject hour, final long[] rest) {
        long h = toLong(context, hour);
        long i = 0;
        final long r0 = rest[0], r1 = rest[1];
        if (r0 != 0) {
            i = (24 * r0) / r1;
            rest[0] = (24 * r0) - (i * r1);
        }
        addRationalModToRest(context, hour, h, rest);

        h += i;
        return (int) (h < 0 ? h + 24 : h); // JODA will handle invalid value
    }

    static int getMinute(ThreadContext context, IRubyObject val, final long[] rest) {
        long v = toLong(context, val);
        long i = 0;
        final long r0 = rest[0], r1 = rest[1];
        if (r0 != 0) {
            i = (60 * r0) / r1;
            rest[0] = (60 * r0) - (i * r1);
        }
        addRationalModToRest(context, val, v, rest);

        v += i;
        return (int) (v < 0 ? v + 60 : v); // JODA will handle invalid value
    }

    private static boolean isSecondAWholeNumber(ThreadContext context, IRubyObject val) {
        if (val instanceof RubyRational rat) {
            RubyInteger den = rat.getDenominator();
            return den instanceof RubyFixnum denf && denf.getValue() == 1;
        } else if (val instanceof RubyFloat flote) {
            double v = flote.asDouble(context);
            return v == (double) Math.round(v);
        }

        return true;
    }

    static int getSecond(ThreadContext context, IRubyObject val, final long[] rest) {
        // MRI: num2int_with_frac(s,n)
        // NOTE: missing "invalid fraction" detection (would be relevant for hour and min)
        boolean wholeNum = isSecondAWholeNumber(context, val);
        long i = 0;
        final long r0 = rest[0], r1 = rest[1];
        if (r0 != 0) {
            i = (60 * r0) / r1;
            rest[0] = (60 * r0) - (i * r1);
        }
        // MRI: s_trunc
        long v;
        if (wholeNum) {
            v = toLong(context, val);
        } else {
            val = ((RubyNumeric) val).divmod(context, asFixnum(context, 1));
            v = ((RubyInteger) ((RubyArray) val).eltInternal(0)).asLong(context);
            RubyNumeric fr = (RubyNumeric) ((RubyArray) val).eltInternal(1);
            // NOTE: we don't:
            // *fr = f_quo(*fr, INT2FIX(86400));
            // since we're calculating directly instead of post-adding as MRI does: Date#+(fr)
            addFraction(context, rest, fr, true); // add_frac() fr2
        }

        v += i;
        return (int) (v < 0 ? v + 60 : v); // JODA will handle invalid value
    }

    private static void addRationalModToRest(ThreadContext context, IRubyObject val, long ival, final long[] rest) {
        if (!(val instanceof RubyInteger) && val instanceof RubyNumeric) { // Rational|Float
            addRationalModToRest(context, ((RubyNumeric) val).convertToRational(context), ival, rest);
        }
    }

    private static void addRationalModToRest(ThreadContext context, RubyRational rat, long ival, final long[] rest) {
        long num = rat.getNumerator().asLong(context);
        long den = rat.getDenominator().asLong(context);
        num -= ival * den;
        if (num != 0) {
            addFraction(context, rest, RubyRational.newRational(context.runtime, num, den), false);
        }
    }

    private static void addFraction(ThreadContext context, final long[] rest, RubyNumeric fr, boolean roundFloat) {
        RubyNumeric res = (RubyNumeric) RubyRational.newRational(context.runtime, rest[0], rest[1]).op_plus(context, fr);
        if (res instanceof RubyRational rat) {
            rest[0] = rat.getNumerator().asLong(context);
            rest[1] = rat.getDenominator().asLong(context);
        } else if (roundFloat && res instanceof RubyFloat flote) {
            // currently only used for (sub) sec rounding - will need a revision for others
            var rat = roundToPrecision(context, flote, SUB_MS_PRECISION * 1000L).convertToRational(context);
            rest[0] = rat.getNumerator().asLong(context);
            rest[1] = rat.getDenominator().asLong(context);
        } else {
            rest[0] = toLong(context, res);
            rest[1] = 1;
        }
    }

    private static void assertValidFraction(ThreadContext context, IRubyObject val, long ival) {
        if (val instanceof RubyRational) {
            IRubyObject eql = ((RubyRational) val).op_equal(context, asFixnum(context, ival));
            if (eql != context.tru) throw newDateError(context, "invalid fraction");
        }
    }

    /**
     # Create a new DateTime object corresponding to the specified
     # Julian Day Number +jd+ and hour +h+, minute +min+, second +s+.
     #
     # The 24-hour clock is used.  Negative values of +h+, +min+, and
     # +sec+ are treating as counting backwards from the end of the
     # next larger unit (e.g. a +min+ of -2 is treated as 58).  No
     # wraparound is performed.  If an invalid time portion is specified,
     # an ArgumentError is raised.
     #
     # +of+ is the offset from UTC as a fraction of a day (defaults to 0).
     # +sg+ specifies the Day of Calendar Reform.
     #
     # All day/time values default to 0.
     */ // jd(jd=0, h=0, min=0, s=0, of=0, sg=ITALY)

    @JRubyMethod(name = "jd", meta = true)
    public static RubyDateTime jd(ThreadContext context, IRubyObject self) { // jd = 0
        return new RubyDateTime(context.runtime, (RubyClass) self, defaultDateTime, 0);
    }

    @JRubyMethod(name = "jd", meta = true, optional = 6, checkArity = false)
    public static RubyDateTime jd(ThreadContext context, IRubyObject self, IRubyObject[] args) {
        int argc = Arity.checkArgumentCount(context, args, 0, 6);

        final RubyFixnum zero = RubyFixnum.zero(context.runtime);

        final long[] rest = new long[] { 0, 1 };
        final long jd = getDay(context, args[0], rest);

        final IRubyObject hour = (argc > 1) ? args[1] : zero;
        final IRubyObject min = (argc > 2) ? args[2] : zero;
        final IRubyObject sec = (argc > 3) ? args[3] : zero;

        final RubyNumeric fr;
        if (hour != zero || min != zero || sec != zero) {
            IRubyObject tmp = _valid_time_p(context, self, hour, min, sec);
            if (tmp == context.nil) throw newDateError(context, "invalid date");
            fr = (RubyNumeric) tmp;
        }
        else {
            fr = zero;
        }

        int off = 0; long sg = ITALY;
        if (argc > 4) off = val2off(context, args[4]);
        if (argc > 5) sg = val2sg(context, args[5]);

        RubyNumeric ajd = jd_to_ajd(context, jd, fr, off);
        return new RubyDateTime(context, (RubyClass) self, ajd, rest, off, sg);
    }

    /**
     # Create a new DateTime object representing the current time.
     #
     # +sg+ specifies the Day of Calendar Reform.
     **/

    @JRubyMethod(meta = true)
    public static RubyDateTime now(ThreadContext context, IRubyObject self) { // sg=ITALY
        final DateTimeZone zone = RubyTime.getLocalTimeZone(context);
        if (zone == DateTimeZone.UTC) {
            return new RubyDateTime(context.runtime, (RubyClass) self, new DateTime(CHRONO_ITALY_UTC), 0);
        }
        final DateTime dt = new DateTime(GJChronology.getInstance(zone));
        final int off = zone.getOffset(dt.getMillis()) / 1000;
        return new RubyDateTime(context.runtime, (RubyClass) self, dt, off, ITALY);
    }

    @JRubyMethod(meta = true)
    public static RubyDateTime now(ThreadContext context, IRubyObject self, IRubyObject sg) {
        final long start = val2sg(context, sg);
        final DateTimeZone zone = RubyTime.getLocalTimeZone(context);
        final DateTime dt = new DateTime(getChronology(context, start, zone));
        final int off = zone.getOffset(dt.getMillis()) / 1000;
        return new RubyDateTime(context.runtime, (RubyClass) self, dt, off, start);
    }

    @Override
    public IRubyObject prev_day(ThreadContext context, IRubyObject n) {
        return prevNextDay(context, n, true);
    }

    @Override
    public IRubyObject next_day(ThreadContext context, IRubyObject n) {
        return prevNextDay(context, n, false);
    }

    private RubyDate prevNextDay(ThreadContext context, IRubyObject n, final boolean negate) {
        long seconds = timesIntDiff(context, n, DAY_IN_SECONDS);
        if (negate) seconds = -seconds;
        final int days = RubyNumeric.checkInt(context.runtime, seconds / DAY_IN_SECONDS);
        return newInstance(context, this.dt.plusDays(days).plusSeconds((int) (seconds % DAY_IN_SECONDS)), off, start);
    }

    private static final ByteList TO_S_FORMAT = new ByteList(ByteList.plain("%.4d-%02d-%02dT%02d:%02d:%02d%s"), false);
    static { TO_S_FORMAT.setEncoding(USASCIIEncoding.INSTANCE); }

    @JRubyMethod
    public RubyString to_s(ThreadContext context) {
        // format('%.4d-%02d-%02dT%02d:%02d:%02d%s', year, mon, mday, hour, min, sec, zone)
        return format(context, TO_S_FORMAT,
                year(context),
                mon(context),
                mday(context),
                hour(context),
                minute(context),
                second(context),
                zone(context)
        );
    }

    @JRubyMethod // Date.civil(year, mon, mday, @sg)
    public RubyDate to_date(ThreadContext context) {
        return new RubyDate(context.runtime, getDate(context), withTimeAt0InZone(dt, DateTimeZone.UTC), 0, start);
    }

    static DateTime withTimeAt0InZone(DateTime dt, DateTimeZone zone) {
        long millis = dt.getZone().getMillisKeepLocal(zone, dt.getMillis());
        final Chronology chronology = dt.getChronology().withZone(zone);
        millis = chronology.millisOfDay().set(millis, 0);
        return new DateTime(millis, chronology);
    }

    @JRubyMethod
    public RubyDateTime to_datetime() { return this; }

    @JRubyMethod // Time.new(year, mon, mday, hour, min, sec + sec_fraction, (@of * 86400.0))
    public RubyTime to_time(ThreadContext context) {
        DateTime dt = this.dt;

        dt = new DateTime(
                adjustJodaYear(dt.getYear()), dt.getMonthOfYear(), dt.getDayOfMonth(),
                dt.getHourOfDay(), dt.getMinuteOfHour(), dt.getSecondOfMinute(),
                dt.getMillisOfSecond(), RubyTime.getTimeZone(context, this.off)
        );

        RubyTime time = new RubyTime(context.runtime, timeClass(context), dt, true);
        if (subMillisNum != 0) {
            RubyNumeric usec = (RubyNumeric) subMillis(context).op_mul(context, asFixnum(context, 1_000_000));
            time.setNSec(usec.asLong(context));
        }
        return time;
    }

    // date/format.rb

    private static final ByteList STRF_FORMAT_BYTES = ByteList.create("%FT%T%:z");
    static { STRF_FORMAT_BYTES.setEncoding(USASCIIEncoding.INSTANCE); }

    @JRubyMethod // def strftime(fmt='%FT%T%:z')
    public RubyString strftime(ThreadContext context) {
        return super.strftime(context, RubyString.newStringLight(context.runtime, STRF_FORMAT_BYTES));
    }

    @JRubyMethod // alias_method :format, :strftime
    public RubyString strftime(ThreadContext context, IRubyObject fmt) {
        return super.strftime(context, fmt);
    }

    private static final String DEFAULT_FORMAT = "%FT%T%z";

    @JRubyMethod(meta = true)
    public static IRubyObject _strptime(ThreadContext context, IRubyObject self, IRubyObject string) {
        return parse(context, string, DEFAULT_FORMAT);
    }

    @JRubyMethod(meta = true)
    public static IRubyObject _strptime(ThreadContext context, IRubyObject self, IRubyObject string, IRubyObject format) {
        return RubyDate._strptime(context, self, string, format);
    }

    // Java API

    /**
     * @return a date time
     */
    public LocalDateTime toLocalDateTime() {
        return LocalDateTime.of(getYear(), getMonth(), getDay(), getHour(), getMinute(), getSecond(), getNanos());
    }

    /**
     * @return a date time
     */
    public ZonedDateTime toZonedDateTime() {
        return ZonedDateTime.of(toLocalDateTime(), ZoneId.of(dt.getZone().getID()));
    }

    /**
     * @return a date time
     */
    public OffsetDateTime toOffsetDateTime() {
        final int offset = dt.getZone().getOffset(dt.getMillis()) / 1000;
        return OffsetDateTime.of(toLocalDateTime(), ZoneOffset.ofTotalSeconds(offset));
    }

    @Override
    public <T> T toJava(Class<T> target) {
        if (target == Comparable.class || target == Object.class) {
            return super.toJava(target);
        }

        // Java 8
        if (target != Serializable.class) {
            if (target.isAssignableFrom(Instant.class)) { // covers Temporal/TemporalAdjuster
                return (T) toInstant();
            }
            if (target.isAssignableFrom(LocalDateTime.class)) { // java.time.chrono.ChronoLocalDateTime.class
                return (T) toLocalDateTime();
            }
            if (target.isAssignableFrom(ZonedDateTime.class)) { // java.time.chrono.ChronoZonedDateTime.class
                return (T) toZonedDateTime();
            }
            if (target.isAssignableFrom(OffsetDateTime.class)) {
                return (T) toOffsetDateTime();
            }
        }

        return super.toJava(target);
    }

}
