/**
 * Copyright © CENRA (2016)
 *
 * remi.clement@espaces-naturels.fr
 * 
 * This software is a multi-platform and portable application based on 
 * the scientifical toolbox to monitor wetlands. With this toolbox it’s 
 * possible to calculate indicators «RhoMéO» to monitor quality, 
 * fonctionalities and urban and agricultural pressures of wetlands. 
 * Indicators are calculated using naturalist data entered and/or imported,
 * and geographic data downloaded from a geographic server.
 * 
 * This software is governed by the CeCILL license under French law and
 * abiding by the rules of distribution of free software.  You can  use, 
 * modify and/ or redistribute the software under the terms of the CeCILL
 * license as circulated by CEA, CNRS and INRIA at the following URL
 * "http://www.cecill.info". 
 * 
 * As a counterpart to the access to the source code and  rights to copy,
 * modify and redistribute granted by the license, users are provided only
 * with a limited warranty  and the software's author,  the holder of the
 * economic rights,  and the successive licensors  have only  limited
 * liability. 
 * 
 * In this respect, the user's attention is drawn to the risks associated
 * with loading,  using,  modifying and/or developing or reproducing the
 * software by the user in light of its specific status of free software,
 * that may mean  that it is complicated to manipulate,  and  that  also
 * therefore means  that it is reserved for developers  and  experienced
 * professionals having in-depth computer knowledge. Users are therefore
 * encouraged to load and test the software's suitability as regards their
 * requirements in conditions enabling the security of their systems and/or 
 * data to be ensured and,  more generally, to use and operate it in the 
 * same conditions as regards security. 
 * 
 * The fact that you are presently reading this means that you have had
 * knowledge of the CeCILL license and that you accept its terms.
*/
package fr.cenra.rhomeo.core.util;

import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.chrono.ChronoZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.regex.Pattern;
import org.apache.sis.util.ObjectConverter;
import org.apache.sis.util.Static;
import org.apache.sis.util.UnconvertibleObjectException;

/**
 * A set of converters to switch from {@link ChronoZonedDateTime} to various types
 * (string, number).
 *
 * @author Alexis Manin (Geomatys)
 */
public class TemporalConverters extends Static {

    /**
     * Literal value expected between temporal fields in a string.
     */
    private static final char DEFAULT_SEPARATOR = ':';

    /**
     * A regex to detect separator character behind each field of a date.
     */
    private static final Pattern SEPARATOR_PATTERN = Pattern.compile("(\\d+)[^\\d](\\+|-)?");
    private static final String SEPARATOR_REPLACEMENT = new StringBuilder("$1")
            .append(DEFAULT_SEPARATOR).append("$2").toString();

    /**
     * A zoned date time formatter whose aim is to find a date formed as following :
     *
     * YYYY MM dd H(H) m(m) s(s) z(zz) (Zone)
     *
     * Time of the day  is optional, and time precision can be from hour to milliseconds.
     * The zone can be a zone id or offset, also optional (even if recommended).
     */
    private static final DateTimeFormatter YEAR_FIRST_DTF =new DateTimeFormatterBuilder()
            .parseLenient()
            .appendPattern("yyyy:M:d[:H:m:s][:xxx][:X][:Z][:0][:VV]")
            .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
            .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
            .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
            .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
            .toFormatter();

    /**
     * A zoned date time formatter whose aim is to find a date formed as following :
     *
     * dd MM YYYY H(H) m(m) s(s) z(zz) (Zone)
     *
     * Time of the day  is optional, and time precision can be from hour to milliseconds.
     * The zone can be a zone id or offset, also optional (even if recommended).
     */
    private static final DateTimeFormatter DAY_FIRST_DTF = new DateTimeFormatterBuilder()
            .parseLenient()
            .appendPattern("d:M:yyyy[:H:m:s][:xxx][:X][:Z][:0][:VV]")
            .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
            .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
            .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
            .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
            .toFormatter();

    private static final DateTimeFormatter DAY_FIRST_DF = new DateTimeFormatterBuilder()
            .parseLenient()
            .appendPattern("d-M-yyyy")
            .toFormatter();

    /**
     * Get timestamp (number of milliseconds since the epoch, UTC) from a given
     * date.
     *
     * @param input The zoned date to extract timestamp from.
     * @return number of milliseconds between given date and the UTC epoch.
     */
    public static long toTimestamp(final ChronoZonedDateTime input) {
        return input.toInstant().toEpochMilli();
    }

    /**
     * Create a new zoned date/time object from a given timestamp (milli from epoch, UTC).
     * @param time The timestamp to read.
     * @return The date object corresponding to input object.
     */
    public static ZonedDateTime fromTimestamp(final long time) {
        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneOffset.UTC);
    }


    /**
     * DATE TO TIMESTAMP
     */
    public static class ZonedDateTime2Long extends AOC<ZonedDateTime, Long> {

        public ZonedDateTime2Long() {
            super(ZonedDateTime.class, Long.class);
        }

        @Override
        public Long apply(ZonedDateTime s) throws UnconvertibleObjectException {
            return toTimestamp(s);
        }

        @Override
        public ObjectConverter<Long, ZonedDateTime> inverse() throws UnsupportedOperationException {
            return new Long2ZonedDateTime();
        }
    }

    /**
     * TIMESTAMP TO DATE
     */
    public static class Long2ZonedDateTime extends AOC<Long, ZonedDateTime> {

        public Long2ZonedDateTime() {
            super(Long.class, ZonedDateTime.class);
        }

        @Override
        public ZonedDateTime apply(Long s) throws UnconvertibleObjectException {
            return fromTimestamp(s);
        }

        @Override
        public ObjectConverter<ZonedDateTime, Long> inverse() throws UnsupportedOperationException {
            return new ZonedDateTime2Long();
        }
    }

    /**
     * STRING TO DATE.
     */
    public static class String2ZonedDateTime extends AOC<String, ZonedDateTime> {

        public String2ZonedDateTime() {
            super(String.class, ZonedDateTime.class);
        }

        @Override
        public ZonedDateTime apply(String s) throws UnconvertibleObjectException {
            // TODO : change case order ?
            try {
                return fromTimestamp(Long.parseLong(s));
            } catch (NumberFormatException e) {
                try {
                    return ZonedDateTime.parse(s, DateTimeFormatter.ISO_ZONED_DATE_TIME);
                } catch (DateTimeParseException e1) {
                    e1.addSuppressed(e);
                    try {
                        return tryFormatters(
                                SEPARATOR_PATTERN.matcher(s).replaceAll(SEPARATOR_REPLACEMENT),
                                YEAR_FIRST_DTF, DAY_FIRST_DTF
                        );
                    } catch (DateTimeParseException e2) {
                        e2.addSuppressed(e1);
                        UnconvertibleObjectException toThrow = new UnconvertibleObjectException("No known format fit input : ".concat(s), e2);
                        throw toThrow;
                    }
                }
            }
        }

        /**
         * Try to read the given string as a zoned date and time. We iterate on
         * each provided formatters until we find one able to parse data.
         *
         * @param toConvert A string to convert as a date.
         * @param formatters A list of formatters to try for conversion.
         * @return The parsed string as a {@link ZonedDateTime}.
         * @throws DateTimeParseException If no provided format can parse given string.
         */
        private ZonedDateTime tryFormatters(final String toConvert, final DateTimeFormatter... formatters) throws DateTimeParseException {
            if (formatters == null || formatters.length < 1) {
                return ZonedDateTime.parse(toConvert);
            }

            final ArrayList<DateTimeParseException> exceptions = new ArrayList<>();
            for (final DateTimeFormatter format : formatters) {
                try {
                    if (format != null) {
                        return ZonedDateTime.parse(toConvert, format);
                    }
                } catch (DateTimeParseException e) {
                    exceptions.add(e);
                }
            }

            if (exceptions.isEmpty()) {
                throw new UnconvertibleObjectException("No format provided for conversion !");
            } else {
                final DateTimeParseException toThrow = exceptions.get(0);
                for (int i = 1 ; i < exceptions.size() ; i++) {
                    toThrow.addSuppressed(exceptions.get(i));
                }

                throw toThrow;
            }
        }

        @Override
        public ObjectConverter<ZonedDateTime, String> inverse() throws UnsupportedOperationException {
            return new ZonedDateTime2String();
        }
    }

    public static class String2LocalDate extends AOC<String, LocalDate> {

        public String2LocalDate() {
            super(String.class, LocalDate.class);
        }

        @Override
        public LocalDate apply(String object) throws UnconvertibleObjectException {
            try {
                return LocalDate.parse(object, DAY_FIRST_DF);
            } catch (DateTimeParseException e) {
                try {
                    return LocalDate.parse(object);
                } catch (DateTimeParseException e2) {
                    // Unreadable as an ISO local date. Maybe it's a date time ?
                    return new String2ZonedDateTime().apply(object).toLocalDate();
                }
            }
        }

        @Override
        public ObjectConverter<LocalDate, String> inverse() throws UnsupportedOperationException {
            return new LocalDate2String();
        }
    }

    /**
     * DATE TO STRING.
     */
    public static class ZonedDateTime2String extends AOC<ZonedDateTime, String> {

        public ZonedDateTime2String() {
            super(ChronoZonedDateTime.class, String.class);
        }

        @Override
        public String apply(ZonedDateTime s) throws UnconvertibleObjectException {
            return String.valueOf(toTimestamp(s));
        }

        @Override
        public ObjectConverter<String, ZonedDateTime> inverse() throws UnsupportedOperationException {
            return new String2ZonedDateTime();
        }
    }

    public static class LocalDate2String extends AOC<LocalDate, String> {

        public LocalDate2String() {
            super(LocalDate.class, String.class);
        }

        @Override
        public String apply(LocalDate object) throws UnconvertibleObjectException {
            try {
                return object.format(DAY_FIRST_DF);
            } catch (DateTimeException e) {
                return object.toString();
            }
        }

        @Override
        public ObjectConverter<String, LocalDate> inverse() throws UnsupportedOperationException {
            return new String2LocalDate();
        }
    }
}
