/*
 * Copyright 2004-2014 H2 Group. Multiple-Licensed under the MPL 2.0,
 * and the EPL 1.0 (http://h2database.com/html/license.html).
 * Initial Developer: H2 Group
 * Iso8601: Initial Developer: Philippe Marschall (firstName dot lastName
 * at gmail dot com)
 */
package org.h2.util;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.Date;
import java.sql.Time;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

import org.h2.api.TimestampWithTimeZone;
import org.h2.message.DbException;
import org.h2.value.Value;
import org.h2.value.ValueDate;
import org.h2.value.ValueTime;
import org.h2.value.ValueTimestamp;
import org.h2.value.ValueTimestampTimeZone;

/**
 * This utility class contains time conversion functions for Java 8
 * Date and Time API classes.
 *
 * <p>This class is implemented using reflection so that it compiles on
 * Java 7 as well.</p>
 *
 * <p>For LocalDate and LocalTime the conversion methods provided by
 * the JDK are used. For OffsetDateTime a custom conversion method is
 * used because it has no equivalent in JDBC. For LocalDateTime a
 * custom conversion method is used instead of the one provided by the
 * JDK as well.</p>
 *
 * <p>Using the JDK provided conversion method for LocalDateTime would
 * introduces some errors in edge cases. Consider the following case:
 * at 2016-03-27 02:00 in Europe/Berlin the clocks were set to
 * 2016-03-27 03:00. This means that 2016-03-27 02:15 does not exist in
 * Europe/Berlin. Unfortunately java.sql.Timestamp is in the the time
 * zone of the JVM. That means if you run a JVM with the time zone
 * Europe/Berlin then the SQL value 'TIMESTAMP 2016-03-27 02:15:00' can
 * not be represented. java.time.LocalDateTime does not have these
 * limitations but if we convert through java.sql.Timestamp we inherit
 * its limitations. Therefore that conversion must be avoided.</p>
 *
 * <p>Once the driver requires Java 8 all the reflection can be removed.</p>
 */
public class LocalDateTimeUtils {

    // Class<java.time.LocalDate>
    private static Class<?> LOCAL_DATE;
    // Class<java.time.LocalTime>
    private static Class<?> LOCAL_TIME;
    // Class<java.time.LocalDateTime>
    private static Class<?> LOCAL_DATE_TIME;
    // Class<java.time.OffsetDateTime>
    private static Class<?> OFFSET_DATE_TIME;
    // Class<java.time.ZoneOffset>
    private static Class<?> ZONE_OFFSET;

    // java.sql.Date#toLocalDate()
    private static Method TO_LOCAL_DATE;
    // java.sql.Time#toLocalTime()
    private static Method TO_LOCAL_TIME;

    // java.sql.Date#valueOf(LocalDate)
    private static Method DATE_VALUE_OF;
    // java.sql.Time#valueOf(LocalTime)
    private static Method TIME_VALUE_OF;

    // java.time.LocalDate#of(int, int, int)
    private static Method LOCAL_DATE_OF_YEAR_MONTH_DAY;
    // java.time.LocalDate#parse(CharSequence)
    private static Method LOCAL_DATE_PARSE;
    // java.time.LocalDate#getYear()
    private static Method LOCAL_DATE_GET_YEAR;
    // java.time.LocalDate#getMonthValue()
    private static Method LOCAL_DATE_GET_MONTH_VALUE;
    // java.time.LocalDate#getDayOfMonth()
    private static Method LOCAL_DATE_GET_DAY_OF_MONTH;
    // java.time.LocalDate#atStartOfDay()
    private static Method LOCAL_DATE_AT_START_OF_DAY;

    // java.time.LocalTime#parse(CharSequence)
    private static Method LOCAL_TIME_PARSE;

    // java.time.LocalDateTime#plusNanos(long)
    private static Method LOCAL_DATE_TIME_PLUS_NANOS;
    // java.time.LocalDateTime#toLocalDate()
    private static Method LOCAL_DATE_TIME_TO_LOCAL_DATE;
    // java.time.LocalDateTime#truncatedTo(TemporalUnit)
    private static Method LOCAL_DATE_TIME_TRUNCATED_TO;
    // java.time.LocalDateTime#parse(CharSequence)
    private static Method LOCAL_DATE_TIME_PARSE;

    // java.time.ZoneOffset#ofTotalSeconds(int)
    private static Method ZONE_OFFSET_OF_TOTAL_SECONDS;

    // java.time.OffsetDateTime#of(LocalDateTime, ZoneOffset)
    private static Method OFFSET_DATE_TIME_OF_LOCAL_DATE_TIME_ZONE_OFFSET;
    // java.time.OffsetDateTime#parse(CharSequence)
    private static Method OFFSET_DATE_TIME_PARSE;
    // java.time.OffsetDateTime#toLocalDateTime()
    private static Method OFFSET_DATE_TIME_TO_LOCAL_DATE_TIME;
    // java.time.OffsetDateTime#getOffset()
    private static Method OFFSET_DATE_TIME_GET_OFFSET;

    // java.time.ZoneOffset#getTotalSeconds()
    private static Method ZONE_OFFSET_GET_TOTAL_SECONDS;

    // java.time.Duration#between(Temporal, Temporal)
    private static Method DURATION_BETWEEN;
    // java.time.Duration#toNanos()
    private static Method DURATION_TO_NANOS;

    // java.time.temporal.ChronoUnit#DAYS
    private static Object CHRONO_UNIT_DAYS;

    private static final boolean IS_JAVA8_DATE_API_PRESENT;


    static {
        boolean isJava8DateApiPresent;
        try {
            LOCAL_DATE = Class.forName("java.time.LocalDate");
            LOCAL_TIME = Class.forName("java.time.LocalTime");
            LOCAL_DATE_TIME = Class.forName("java.time.LocalDateTime");
            OFFSET_DATE_TIME = Class.forName("java.time.OffsetDateTime");
            ZONE_OFFSET = Class.forName("java.time.ZoneOffset");
            isJava8DateApiPresent = true;
        } catch (ClassNotFoundException e) {
            // older than Java 8
            isJava8DateApiPresent = false;
        }
        IS_JAVA8_DATE_API_PRESENT = isJava8DateApiPresent;

        if (IS_JAVA8_DATE_API_PRESENT) {

            Class<?> temporalUnit = getClass("java.time.temporal.TemporalUnit");
            Class<?> chronoUnit = getClass("java.time.temporal.ChronoUnit");
            Class<?> duration = getClass("java.time.Duration");
            Class<?> temporal = getClass("java.time.temporal.Temporal");

            TO_LOCAL_DATE = getMethod(java.sql.Date.class, "toLocalDate");
            TO_LOCAL_TIME = getMethod(java.sql.Time.class, "toLocalTime");

            DATE_VALUE_OF = getMethod(java.sql.Date.class, "valueOf", LOCAL_DATE);
            TIME_VALUE_OF = getMethod(java.sql.Time.class, "valueOf", LOCAL_TIME);

            LOCAL_DATE_OF_YEAR_MONTH_DAY = getMethod(LOCAL_DATE, "of",
                    int.class, int.class, int.class);
            LOCAL_DATE_PARSE = getMethod(LOCAL_DATE, "parse",
                    CharSequence.class);
            LOCAL_DATE_GET_YEAR = getMethod(LOCAL_DATE, "getYear");
            LOCAL_DATE_GET_MONTH_VALUE = getMethod(LOCAL_DATE, "getMonthValue");
            LOCAL_DATE_GET_DAY_OF_MONTH = getMethod(LOCAL_DATE, "getDayOfMonth");
            LOCAL_DATE_AT_START_OF_DAY = getMethod(LOCAL_DATE, "atStartOfDay");

            LOCAL_TIME_PARSE = getMethod(LOCAL_TIME, "parse", CharSequence.class);

            LOCAL_DATE_TIME_PLUS_NANOS = getMethod(LOCAL_DATE_TIME, "plusNanos", long.class);
            LOCAL_DATE_TIME_TO_LOCAL_DATE = getMethod(LOCAL_DATE_TIME, "toLocalDate");
            LOCAL_DATE_TIME_TRUNCATED_TO = getMethod(LOCAL_DATE_TIME, "truncatedTo", temporalUnit);
            LOCAL_DATE_TIME_PARSE = getMethod(LOCAL_DATE_TIME, "parse", CharSequence.class);

            ZONE_OFFSET_OF_TOTAL_SECONDS = getMethod(ZONE_OFFSET, "ofTotalSeconds", int.class);

            OFFSET_DATE_TIME_TO_LOCAL_DATE_TIME = getMethod(OFFSET_DATE_TIME, "toLocalDateTime");
            OFFSET_DATE_TIME_GET_OFFSET = getMethod(OFFSET_DATE_TIME, "getOffset");
            OFFSET_DATE_TIME_OF_LOCAL_DATE_TIME_ZONE_OFFSET = getMethod(
                    OFFSET_DATE_TIME, "of", LOCAL_DATE_TIME, ZONE_OFFSET);
            OFFSET_DATE_TIME_PARSE = getMethod(OFFSET_DATE_TIME, "parse", CharSequence.class);

            ZONE_OFFSET_GET_TOTAL_SECONDS = getMethod(ZONE_OFFSET, "getTotalSeconds");

            DURATION_BETWEEN = getMethod(duration, "between", temporal, temporal);
            DURATION_TO_NANOS = getMethod(duration, "toNanos");

            CHRONO_UNIT_DAYS = getFieldValue(chronoUnit, "DAYS");
        }
    }

    private LocalDateTimeUtils() {
        // utility class
    }

    /**
     * Checks if the Java 8 Date and Time API is present.
     *
     * <p>This is the case on Java 8 and later and not the case on
     * Java 7. Versions older than Java 7 are not supported.</p>
     *
     * @return if the Java 8 Date and Time API is present
     */
    public static boolean isJava8DateApiPresent() {
        return IS_JAVA8_DATE_API_PRESENT;
    }

    /**
     * Returns the class java.time.LocalDate.
     *
     * @return the class java.time.LocalDate, null on Java 7
     */
    public static Class<?> getLocalDateClass() {
        return LOCAL_DATE;
    }


    /**
     * Returns the class java.time.LocalTime.
     *
     * @return the class java.time.LocalTime, null on Java 7
     */
    public static Class<?> getLocalTimeClass() {
        return LOCAL_TIME;
    }

    /**
     * Returns the class java.time.LocalDateTime.
     *
     * @return the class java.time.LocalDateTime, null on Java 7
     */
    public static Class<?> getLocalDateTimeClass() {
        return LOCAL_DATE_TIME;
    }

    /**
     * Returns the class java.time.OffsetDateTime.
     *
     * @return the class java.time.OffsetDateTime, null on Java 7
     */
    public static Class<?> getOffsetDateTimeClass() {
        return OFFSET_DATE_TIME;
    }

    /**
     * Parses an ISO date string into a java.time.LocalDate.
     *
     * @param text the ISO date string
     * @return the java.time.LocalDate instance
     */
    public static Object parseLocalDate(CharSequence text) {
        try {
            return LOCAL_DATE_PARSE.invoke(null, text);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new IllegalArgumentException("error when parsing text '" + text + "'", e);
        }
    }

    /**
     * Parses an ISO time string into a java.time.LocalTime.
     *
     * @param text the ISO time string
     * @return the java.time.LocalTime instance
     */
    public static Object parseLocalTime(CharSequence text) {
        try {
            return LOCAL_TIME_PARSE.invoke(null, text);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new IllegalArgumentException("error when parsing text '" + text + "'", e);
        }
    }

    /**
     * Parses an ISO date string into a java.time.LocalDateTime.
     *
     * @param text the ISO date string
     * @return the java.time.LocalDateTime instance
     */
    public static Object parseLocalDateTime(CharSequence text) {
        try {
            return LOCAL_DATE_TIME_PARSE.invoke(null, text);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new IllegalArgumentException("error when parsing text '" + text + "'", e);
        }
    }

    /**
     * Parses an ISO date string into a java.time.OffsetDateTime.
     *
     * @param text the ISO date string
     * @return the java.time.OffsetDateTime instance
     */
    public static Object parseOffsetDateTime(CharSequence text) {
        try {
            return OFFSET_DATE_TIME_PARSE.invoke(null, text);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new IllegalArgumentException("error when parsing text '" + text + "'", e);
        }
    }

    private static Class<?> getClass(String className) {
        try {
            return Class.forName(className);
        } catch (ClassNotFoundException e) {
            throw new IllegalStateException("Java 8 or later but class " +
                    className + " is missing", e);
        }
    }

    private static Method getMethod(Class<?> clazz, String methodName,
            Class<?>... parameterTypes) {
        try {
            return clazz.getMethod(methodName, parameterTypes);
        } catch (NoSuchMethodException e) {
            throw new IllegalStateException("Java 8 or later but method " +
                    clazz.getName() + "#" + methodName + "(" +
                    Arrays.toString(parameterTypes) + ") is missing", e);
        }
    }

    private static Object getFieldValue(Class<?> clazz, String fieldName) {
        try {
            return clazz.getField(fieldName).get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new IllegalStateException("Java 8 or later but field " +
                    clazz.getName() + "#" + fieldName + " is missing", e);
        }
    }

    /**
     * Checks if the given class is LocalDate.
     *
     * <p>This method can be called from Java 7.</p>
     *
     * @param clazz the class to check
     * @return if the class is LocalDate
     */
    public static boolean isLocalDate(Class<?> clazz) {
        return LOCAL_DATE == clazz;
    }

    /**
     * Checks if the given class is LocalTime.
     *
     * <p>This method can be called from Java 7.</p>
     *
     * @param clazz the class to check
     * @return if the class is LocalTime
     */
    public static boolean isLocalTime(Class<?> clazz) {
        return LOCAL_TIME == clazz;
    }

    /**
     * Checks if the given class is LocalDateTime.
     *
     * <p>This method can be called from Java 7.</p>
     *
     * @param clazz the class to check
     * @return if the class is LocalDateTime
     */
    public static boolean isLocalDateTime(Class<?> clazz) {
        return LOCAL_DATE_TIME == clazz;
    }

    /**
     * Checks if the given class is OffsetDateTime.
     *
     * <p>This method can be called from Java 7.</p>
     *
     * @param clazz the class to check
     * @return if the class is OffsetDateTime
     */
    public static boolean isOffsetDateTime(Class<?> clazz) {
        return OFFSET_DATE_TIME == clazz;
    }

    /**
     * Converts a value to a LocalDate.
     *
     * <p>This method should only called from Java 8 or later.</p>
     *
     * @param value the value to convert
     * @return the LocalDate
     */
    public static Object valueToLocalDate(Value value) {
        return dateToLocalDate(value.getDate());
    }

    /**
     * Converts a value to a LocalTime.
     *
     * <p>This method should only called from Java 8 or later.</p>
     *
     * @param value the value to convert
     * @return the LocalTime
     */
    public static Object valueToLocalTime(Value value) {
        return timeToLocalTime(value.getTime());
    }

    private static Object dateToLocalDate(Date date) {
        try {
            return TO_LOCAL_DATE.invoke(date);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "date conversion failed");
        }
    }

    private static Object timeToLocalTime(Time time) {
        try {
            return TO_LOCAL_TIME.invoke(time);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "time conversion failed");
        }
    }

    /**
     * Converts a value to a LocalDateTime.
     *
     * <p>This method should only called from Java 8 or later.</p>
     *
     * @param value the value to convert
     * @return the LocalDateTime
     */
    public static Object valueToLocalDateTime(ValueTimestamp value) {

        long dateValue = value.getDateValue();
        long timeNanos = value.getTimeNanos();
        try {
            Object localDate = localDateFromDateValue(dateValue);
            Object localDateTime = LOCAL_DATE_AT_START_OF_DAY.invoke(localDate);
            return LOCAL_DATE_TIME_PLUS_NANOS.invoke(localDateTime, timeNanos);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "timestamp conversion failed");
        }
    }

    /**
     * Converts a value to a OffsetDateTime.
     *
     * <p>This method should only called from Java 8 or later.</p>
     *
     * @param value the value to convert
     * @return the OffsetDateTime
     */
    public static Object valueToOffsetDateTime(ValueTimestampTimeZone value) {
        return timestampWithTimeZoneToOffsetDateTime((TimestampWithTimeZone) value.getObject());
    }

    private static Object timestampWithTimeZoneToOffsetDateTime(
            TimestampWithTimeZone timestampWithTimeZone) {

        long dateValue = timestampWithTimeZone.getYMD();
        long timeNanos = timestampWithTimeZone.getNanosSinceMidnight();
        try {
            Object localDateTime = localDateTimeFromDateNanos(dateValue, timeNanos);

            short timeZoneOffsetMins = timestampWithTimeZone.getTimeZoneOffsetMins();
            int offsetSeconds = (int) TimeUnit.MINUTES.toSeconds(timeZoneOffsetMins);

            Object offset = ZONE_OFFSET_OF_TOTAL_SECONDS.invoke(null, offsetSeconds);

            return OFFSET_DATE_TIME_OF_LOCAL_DATE_TIME_ZONE_OFFSET.invoke(null,
                    localDateTime, offset);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "timestamp with time zone conversion failed");
        }
    }

    /**
     * Converts a LocalDate to a Value.
     *
     * @param localDate the LocalDate to convert, not {@code null}
     * @return the value
     */
    public static Value localDateToDateValue(Object localDate) {
        try {
            Date date = (Date) DATE_VALUE_OF.invoke(null, localDate);
            return ValueDate.get(date);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "date conversion failed");
        }
    }

    /**
     * Converts a LocalTime to a Value.
     *
     * @param localTime the LocalTime to convert, not {@code null}
     * @return the value
     */
    public static Value localTimeToTimeValue(Object localTime) {
        try {
            Time time = (Time) TIME_VALUE_OF.invoke(null, localTime);
            return ValueTime.get(time);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "time conversion failed");
        }
    }

    /**
     * Converts a LocalDateTime to a Value.
     *
     * @param localDateTime the LocalDateTime to convert, not {@code null}
     * @return the value
     */
    public static Value localDateTimeToValue(Object localDateTime) {
        try {
            Object localDate = LOCAL_DATE_TIME_TO_LOCAL_DATE.invoke(localDateTime);
            long dateValue = dateValueFromLocalDate(localDate);
            long timeNanos = timeNanosFromLocalDate(localDateTime);
            return ValueTimestamp.fromDateValueAndNanos(dateValue, timeNanos);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "local date time conversion failed");
        }
    }

    /**
     * Converts a OffsetDateTime to a Value.
     *
     * @param offsetDateTime the OffsetDateTime to convert, not {@code null}
     * @return the value
     */
    public static Value offsetDateTimeToValue(Object offsetDateTime) {
        try {
            Object localDateTime = OFFSET_DATE_TIME_TO_LOCAL_DATE_TIME.invoke(offsetDateTime);
            Object localDate = LOCAL_DATE_TIME_TO_LOCAL_DATE.invoke(localDateTime);
            Object zoneOffset = OFFSET_DATE_TIME_GET_OFFSET.invoke(offsetDateTime);

            long dateValue = dateValueFromLocalDate(localDate);
            long timeNanos = timeNanosFromLocalDate(localDateTime);
            short timeZoneOffsetMins = zoneOffsetToOffsetMinute(zoneOffset);
            return ValueTimestampTimeZone.fromDateValueAndNanos(dateValue,
                    timeNanos, timeZoneOffsetMins);
        } catch (IllegalAccessException e) {
            throw DbException.convert(e);
        } catch (InvocationTargetException e) {
            throw DbException.convertInvocation(e, "time conversion failed");
        }
    }

    private static long dateValueFromLocalDate(Object localDate)
                    throws IllegalAccessException, InvocationTargetException {
        int year = (Integer) LOCAL_DATE_GET_YEAR.invoke(localDate);
        int month = (Integer) LOCAL_DATE_GET_MONTH_VALUE.invoke(localDate);
        int day = (Integer) LOCAL_DATE_GET_DAY_OF_MONTH.invoke(localDate);
        return DateTimeUtils.dateValue(year, month, day);
    }

    private static long timeNanosFromLocalDate(Object localDateTime)
                    throws IllegalAccessException, InvocationTargetException {
        Object midnight = LOCAL_DATE_TIME_TRUNCATED_TO.invoke(localDateTime, CHRONO_UNIT_DAYS);
        Object duration = DURATION_BETWEEN.invoke(null, midnight, localDateTime);
        return (Long) DURATION_TO_NANOS.invoke(duration);
    }

    private static short zoneOffsetToOffsetMinute(Object zoneOffset)
                    throws IllegalAccessException, InvocationTargetException {
        int totalSeconds = (Integer) ZONE_OFFSET_GET_TOTAL_SECONDS.invoke(zoneOffset);
        return (short) TimeUnit.SECONDS.toMinutes(totalSeconds);
    }

    private static Object localDateFromDateValue(long dateValue)
                    throws IllegalAccessException, InvocationTargetException {

        int year = DateTimeUtils.yearFromDateValue(dateValue);
        int month = DateTimeUtils.monthFromDateValue(dateValue);
        int day = DateTimeUtils.dayFromDateValue(dateValue);

        return LOCAL_DATE_OF_YEAR_MONTH_DAY.invoke(null, year, month, day);
    }

    private static Object localDateTimeFromDateNanos(long dateValue, long timeNanos)
                    throws IllegalAccessException, InvocationTargetException {
        Object localDate = localDateFromDateValue(dateValue);
        Object localDateTime = LOCAL_DATE_AT_START_OF_DAY.invoke(localDate);
        return LOCAL_DATE_TIME_PLUS_NANOS.invoke(localDateTime, timeNanos);
    }

}
