/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.brooklyn.util.time;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Locale;
import java.util.SimpleTimeZone;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

import org.apache.brooklyn.util.collections.MutableList;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.text.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Function;
import com.google.common.base.Preconditions;
import com.google.common.base.Stopwatch;

public class Time {

    private static final Logger log = LoggerFactory.getLogger(Time.class);
    
    public static final String DATE_FORMAT_PREFERRED_W_TZ = "yyyy-MM-dd HH:mm:ss.SSS Z";
    public static final String DATE_FORMAT_PREFERRED = "yyyy-MM-dd HH:mm:ss.SSS";
    public static final String DATE_FORMAT_STAMP = "yyyyMMdd-HHmmssSSS";
    public static final String DATE_FORMAT_SIMPLE_STAMP = "yyyy-MM-dd-HHmm";
    public static final String DATE_FORMAT_OF_DATE_TOSTRING = "EEE MMM dd HH:mm:ss zzz yyyy";
    public static final String DATE_FORMAT_ISO8601 = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
    public static final String DATE_FORMAT_ISO8601_NO_MILLIS = "yyyy-MM-dd'T'HH:mm:ssZ";

    public static final long MILLIS_IN_SECOND = 1000;
    public static final long MILLIS_IN_MINUTE = 60*MILLIS_IN_SECOND;
    public static final long MILLIS_IN_HOUR = 60*MILLIS_IN_MINUTE;
    public static final long MILLIS_IN_DAY = 24*MILLIS_IN_HOUR;
    public static final long MILLIS_IN_YEAR = 365*MILLIS_IN_DAY;
    
    /** GMT/UTC/Z time zone constant */
    public static final TimeZone TIME_ZONE_UTC = TimeZone.getTimeZone("");
    
    /** as {@link #makeDateString(Date)} for current date/time */
    public static String makeDateString() {
        return makeDateString(System.currentTimeMillis());
    }

    /** as {@link #makeDateString(Date)} for long millis since UTC epock */
    public static String makeDateString(long date) {
        return makeDateString(new Date(date), DATE_FORMAT_PREFERRED);
    }
    /** returns the time in {@value #DATE_FORMAT_PREFERRED} format for the given date;
     * this format is numeric big-endian but otherwise optimized for people to read, with spaces and colons and dots;
     * time is local to the server and time zone is <i>not</i> included */
    public static String makeDateString(Date date) {
        return makeDateString(date, DATE_FORMAT_PREFERRED);
    }
    /** as {@link #makeDateString(Date, String, TimeZone)} for the local time zone */
    public static String makeDateString(Date date, String format) {
        return makeDateString(date, format, null);
    }
    /** as {@link #makeDateString(Date, String, TimeZone)} for the given time zone; consider {@link TimeZone#GMT} */
    public static String makeDateString(Date date, String format, @Nullable TimeZone tz) {
        SimpleDateFormat fmt = new SimpleDateFormat(format);
        if (tz!=null) fmt.setTimeZone(tz);
        return fmt.format(date);
    }
    /** as {@link #makeDateString(Date, String)} using {@link #DATE_FORMAT_PREFERRED_W_TZ} */
    public static String makeDateString(Calendar date) {
        return makeDateString(date.getTime(), DATE_FORMAT_PREFERRED_W_TZ);
    }
    /** as {@link #makeDateString(Date, String, TimeZone)} for the time zone of the given calendar object */
    public static String makeDateString(Calendar date, String format) {
        return makeDateString(date.getTime(), format, date.getTimeZone());
    }

    public static Function<Long, String> toDateString() {
        return dateString;
    }
    private static Function<Long, String> dateString = new Function<Long, String>() {
            @Override
            @Nullable
            public String apply(@Nullable Long input) {
                if (input == null) return null;
                return Time.makeDateString(input);
            }
        };

    /** returns the current time in {@value #DATE_FORMAT_STAMP} format,
     * suitable for machines to read with only numbers and dashes and quite precise (ms) */
    public static String makeDateStampString() {
        return makeDateStampString(System.currentTimeMillis());
    }

    /** returns the time in {@value #DATE_FORMAT_STAMP} format, given a long (e.g. returned by System.currentTimeMillis);
     * cf {@link #makeDateStampString()} */
    public static String makeDateStampString(long date) {
        return new SimpleDateFormat(DATE_FORMAT_STAMP).format(new Date(date));
    }

    /** returns the current time in {@value #DATE_FORMAT_SIMPLE_STAMP} format, 
     * suitable for machines to read but easier for humans too, 
     * like {@link #makeDateStampString()} but not as precise */
    public static String makeDateSimpleStampString() {
        return makeDateSimpleStampString(System.currentTimeMillis());
    }

    /** returns the time in {@value #DATE_FORMAT_SIMPLE_STAMP} format, given a long (e.g. returned by System.currentTimeMillis);
     * cf {@link #makeDateSimpleStampString()} */
    public static String makeDateSimpleStampString(long date) {
        return new SimpleDateFormat(DATE_FORMAT_SIMPLE_STAMP).format(new Date(date));
    }

    public static Function<Long, String> toDateStampString() {
        return dateStampString;
    }
    private static Function<Long, String> dateStampString = new Function<Long, String>() {
            @Override
            @Nullable
            public String apply(@Nullable Long input) {
                if (input == null) return null;
                return Time.makeDateStampString(input);
            }
        };

    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringExact(long t, TimeUnit unit) {
        long nanos = unit.toNanos(t);
        return makeTimeStringNanoExact(nanos);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringRounded(long t, TimeUnit unit) {
        long nanos = unit.toNanos(t);
        return makeTimeStringNanoRounded(nanos);
    }
    /**
     * A nice string representation of the stopwatch's elapsed time; or null if null is passed in.
     */
    public static String makeTimeStringRounded(@Nullable Stopwatch timer) {
        return (timer == null) ? null : makeTimeStringRounded(timer.elapsed(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringExact(long t) {
        return makeTimeString(t, false);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringRounded(long t) {
        return makeTimeString(t, true);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringRoundedSince(long utc) {
        return makeTimeString(System.currentTimeMillis() - utc, true);
    }
    /**
     * A nice string representation of the duration; or null if null is passed in.
     * @see #makeTimeString(long, boolean)
     */
    public static String makeTimeStringExact(@Nullable Duration d) {
        return (d == null) ? null : makeTimeStringNanoExact(d.toNanoseconds());
    }
    /**
     * A nice string representation of the duration; or null if null is passed in.
     * @see #makeTimeString(long, boolean)
     */
    public static String makeTimeStringRounded(@Nullable Duration d) {
        return (d == null) ? null : makeTimeStringNanoRounded(d.toNanoseconds());
    }
    /** given an elapsed time, makes it readable, eg 44d 6h, or 8s 923ms, optionally rounding */
    public static String makeTimeString(long t, boolean round) {
        return makeTimeStringNano(t*1000000L, round);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringNanoExact(long tn) {
        return makeTimeStringNano(tn, false);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringNanoRounded(long tn) {
        return makeTimeStringNano(tn, true);
    }
    /** @see #makeTimeString(long, boolean) */
    public static String makeTimeStringNano(long tn, boolean round) {
        if (tn<0) return "-"+makeTimeStringNano(-tn, round);
        // units don't matter, but since ms is the usual finest granularity let's use it
        // (previously was just "0" but that was too ambiguous in contexts like "took 0")
        if (tn==0) return "0ms";
        
        long tnm = tn % 1000000;
        long t = tn/1000000;
        String result = "";
        
        long d = t/MILLIS_IN_DAY;  t %= MILLIS_IN_DAY;
        long h = t/MILLIS_IN_HOUR;  t %= MILLIS_IN_HOUR;
        long m = t/MILLIS_IN_MINUTE;  t %= MILLIS_IN_MINUTE;
        long s = t/MILLIS_IN_SECOND;  t %= MILLIS_IN_SECOND;
        long ms = t;
        
        int segments = 0;
        if (d>0) { result += d+"d "; segments++; }
        if (h>0) { result += h+"h "; segments++; }
        if (round && segments>=2) return Strings.removeAllFromEnd(result, " ");
        if (m>0) { result += m+"m "; segments++; }
        if (round && (segments>=2 || d>0)) return Strings.removeAllFromEnd(result, " ");
        if (s>0) {
            if (ms==0 && tnm==0) {
                result += s+"s"; segments++;
                return result;
            }
            if (round && segments>0) {
                result += s+"s"; segments++;
                return result;
            }
            if (round && s>10) {
                result += toDecimal(s, ms/1000.0, 1)+"s"; segments++;
                return result;
            }
            if (round) {
                result += toDecimal(s, ms/1000.0, 2)+"s"; segments++;
                return result;
            }
            result += s+"s ";
        }
        if (round && segments>0)
            return Strings.removeAllFromEnd(result, " ");
        if (ms>0) {
            if (tnm==0) {
                result += ms+"ms"; segments++;
                return result;
            }
            if (round && ms>=100) {
                result += toDecimal(ms, tnm/1000000.0, 1)+"ms"; segments++;
                return result;
            }
            if (round && ms>=10) {
                result += toDecimal(ms, tnm/1000000.0, 2)+"ms"; segments++;
                return result;
            }
            if (round) {
                result += toDecimal(ms, tnm/1000000.0, 3)+"ms"; segments++;
                return result;
            }
            result += ms+"ms ";
        }
        
        long us = tnm/1000;
        long ns = tnm % 1000;

        if (us>0) {
            if (ns==0) {
                result += us+"us"; segments++;
                return result;
            }
            if (round && us>=100) {
                result += toDecimal(us, ns/1000.0, 1)+"us"; segments++;
                return result;
            }
            if (round && us>=10) {
                result += toDecimal(us, ns/1000.0, 2)+"us"; segments++;
                return result;
            }
            if (round) {
                result += toDecimal(us, ns/1000.0, 3)+"us"; segments++;
                return result;
            }
            result += us+"us ";
        }

        if (ns>0) result += ns+"ns";
        return Strings.removeAllFromEnd(result, " ");
    }

    public static Function<Long, String> fromLongToTimeStringExact() {
        return LONG_TO_TIME_STRING_EXACT;
    }
    private static final Function<Long, String> LONG_TO_TIME_STRING_EXACT = new FunctionLongToTimeStringExact();
    private static final class FunctionLongToTimeStringExact implements Function<Long, String> {
        @Override @Nullable
        public String apply(@Nullable Long input) {
            if (input == null) return null;
            return Time.makeTimeStringExact(input);
        }
    }

    /** @deprecated since 0.7.0; kept for persisted state backwards compatibility */
    @Deprecated
    private static Function<Long, String> timeString = new Function<Long, String>() {
            @Override
            @Nullable
            public String apply(@Nullable Long input) {
                if (input == null) return null;
                return Time.makeTimeStringExact(input);
            }
        };
        
    public static Function<Long, String> fromLongToTimeStringRounded() {
        return LONG_TO_TIME_STRING_ROUNDED;
    }
    private static final Function<Long, String> LONG_TO_TIME_STRING_ROUNDED = new FunctionLongToTimeStringRounded();
    private static final class FunctionLongToTimeStringRounded implements Function<Long, String> {
        @Override @Nullable
        public String apply(@Nullable Long input) {
            if (input == null) return null;
            return Time.makeTimeStringRounded(input);
        }
    }

    /** @deprecated since 0.7.0; kept for persisted state backwards compatibility */
    @Deprecated
    private static Function<Long, String> timeStringRounded = new Function<Long, String>() {
        @Override
        @Nullable
        public String apply(@Nullable Long input) {
            if (input == null) return null;
            return Time.makeTimeStringRounded(input);
        }
    };

    public static Function<Duration, String> fromDurationToTimeStringRounded() {
        return DURATION_TO_TIME_STRING_ROUNDED;
    }
    private static final Function<Duration, String> DURATION_TO_TIME_STRING_ROUNDED = new FunctionDurationToTimeStringRounded();
    private static final class FunctionDurationToTimeStringRounded implements Function<Duration, String> {
        @Override @Nullable
        public String apply(@Nullable Duration input) {
            if (input == null) return null;
            return Time.makeTimeStringRounded(input);
        }
    }

    private static String toDecimal(long intPart, double fracPart, int decimalPrecision) {
        long powTen = 1;
        for (int i=0; i<decimalPrecision; i++) powTen *= 10;
        long fpr = Math.round(fracPart * powTen);
        if (fpr==powTen) {
            intPart++;
            fpr = 0;
        }
        return intPart + "." + Strings.makePaddedString(""+fpr, decimalPrecision, "0", "");
    }

    /** sleep which propagates Interrupted as unchecked */
    public static void sleep(long millis) {
        try {
            if (millis > 0) Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw Exceptions.propagate(e);
        }
    }
    
    /** as {@link #sleep(long)} */
    public static void sleep(Duration duration) {
        Time.sleep(duration.toMillisecondsRoundingUp());
    }    

    /**
     * Calculates the number of milliseconds past midnight for a given UTC time.
     */
    public static long getTimeOfDayFromUtc(long timeUtc) {
        GregorianCalendar gregorianCalendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
        gregorianCalendar.setTimeInMillis(timeUtc);
        int hour = gregorianCalendar.get(Calendar.HOUR_OF_DAY);
        int min = gregorianCalendar.get(Calendar.MINUTE);
        int sec = gregorianCalendar.get(Calendar.SECOND);
        int millis = gregorianCalendar.get(Calendar.MILLISECOND);
        return (((((hour * 60) + min) * 60) + sec) * 1000) + millis;
    }
    
    /**
     * Calculates the number of milliseconds past epoch for a given UTC time.
     */
    public static long getTimeUtc(TimeZone zone, int year, int month, int date, int hourOfDay, int minute, int second, int millis) {
        GregorianCalendar time = new GregorianCalendar(zone);
        time.set(year, month, date, hourOfDay, minute, second);
        time.set(Calendar.MILLISECOND, millis);
        return time.getTimeInMillis();
    }
    
    public static long roundFromMillis(long millis, TimeUnit units) {
        if (units.compareTo(TimeUnit.MILLISECONDS) > 0) {
            double result = ((double)millis) / units.toMillis(1);
            return Math.round(result);
        } else {
            return units.convert(millis, TimeUnit.MILLISECONDS);
        }
    }
    
    public static long roundFromMillis(long millis, long millisPerUnit) {
        double result = ((double)millis) / millisPerUnit;
        return Math.round(result);
    }
    
    /**
     * Calculates how long until maxTime has passed since the given startTime. 
     * However, maxTime==0 is a special case (e.g. could mean wait forever), so the result is guaranteed
     * to be only 0 if maxTime was 0; otherwise -1 will be returned.
     */
    public static long timeRemaining(long startTime, long maxTime) {
        if (maxTime == 0) {
            return 0;
        }
        long result = (startTime+maxTime) - System.currentTimeMillis();
        return (result == 0) ? -1 : result;
    }
    
    /** Convenience for {@link Duration#parse(String)}. */
    public static Duration parseDuration(@Nullable String timeString) {
        return Duration.parse(timeString);
    }
    
    /** 
     * As {@link #parseElapsedTimeAsDouble(String)}. Consider using {@link #parseDuration(String)} for a more usable return type.
     * 
     * @throws NumberFormatException if cannot be parsed (or if null)
     */
    public static long parseElapsedTime(String timeString) {
        return (long) parseElapsedTimeAsDouble(timeString);
    }
    
    /** 
     * Parses a string eg '5s' or '20m 22.123ms', returning the number of milliseconds it represents; 
     * -1 on blank or never or off or false.
     * Assumes unit is millisections if no unit is specified.
     * 
     * @throws NullPointerException if arg is null
     * @throws NumberFormatException if cannot be parsed
     */
    public static double parseElapsedTimeAsDouble(final String timeStringOrig) {
        String timeString = timeStringOrig;
        if (timeString==null)
            throw new NullPointerException("GeneralHelper.parseTimeString cannot parse a null string");
        try {
            double d = Double.parseDouble(timeString);
            return d;
        } catch (NumberFormatException e) {
            try {
                //look for a type marker
                timeString = timeString.trim();
                String s = Strings.getLastWord(timeString).toLowerCase();
                timeString = timeString.substring(0, timeString.length()-s.length()).trim();
                int i=0;
                while (s.length()>i) {
                    char c = s.charAt(i);
                    if (c=='.' || Character.isDigit(c)) i++;
                    else break;
                }
                String num = s.substring(0, i);
                if (i==0) {
                    if (Strings.isNonBlank(timeString)) {
                        num = Strings.getLastWord(timeString).toLowerCase();
                        timeString = timeString.substring(0, timeString.length()-num.length()).trim();
                    }
                } else {
                    s = s.substring(i);
                }
                long multiplier = 0;
                if (num.length()==0) {
                    //must be never or something
                    // TODO does 'never' work?
                    if (s.equalsIgnoreCase("never") || s.equalsIgnoreCase("off") || s.equalsIgnoreCase("false"))
                        return -1;
                    throw new NumberFormatException("unrecognised word  '"+s+"' in time string");
                }
                if (s.equalsIgnoreCase("ms") || s.equalsIgnoreCase("milli") || s.equalsIgnoreCase("millis")
                    || s.equalsIgnoreCase("millisec") || s.equalsIgnoreCase("millisecs")
                    || s.equalsIgnoreCase("millisecond") || s.equalsIgnoreCase("milliseconds"))
                    multiplier = 1;
                else if (s.equalsIgnoreCase("s") || s.equalsIgnoreCase("sec") || s.equalsIgnoreCase("secs")
                    || s.equalsIgnoreCase("second") || s.equalsIgnoreCase("seconds"))
                    multiplier = 1000;
                else if (s.equalsIgnoreCase("m") || s.equalsIgnoreCase("min") || s.equalsIgnoreCase("mins")
                    || s.equalsIgnoreCase("minute") || s.equalsIgnoreCase("minutes"))
                    multiplier = 60*1000;
                else if (s.equalsIgnoreCase("h") || s.equalsIgnoreCase("hr") || s.equalsIgnoreCase("hrs")
                    || s.equalsIgnoreCase("hour") || s.equalsIgnoreCase("hours"))
                    multiplier = 60*60*1000;
                else if (s.equalsIgnoreCase("d") || s.equalsIgnoreCase("day") || s.equalsIgnoreCase("days"))
                    multiplier = 24*60*60*1000;
                else
                    throw new NumberFormatException("Unknown unit '"+s+"' in time string '"+timeStringOrig+"'");
                double d = Double.parseDouble(num);
                double dd = 0;
                if (timeString.length()>0) {
                    dd = parseElapsedTimeAsDouble(timeString);
                    if (dd==-1) {
                        throw new NumberFormatException("Cannot combine '"+timeString+"' with '"+num+" "+s+"'");
                    }
                }
                return d*multiplier + dd;
            } catch (Exception ex) {
                if (ex instanceof NumberFormatException) throw (NumberFormatException)ex;
                log.trace("Details of parse failure:", ex);
                throw new NumberFormatException("Cannot parse time string '"+timeStringOrig+"'");
            }
        }
    }

    public static Calendar newCalendarFromMillisSinceEpochUtc(long timestamp) {
        GregorianCalendar cal = new GregorianCalendar();
        cal.setTimeInMillis(timestamp);
        return cal;
    }

    public static Calendar newCalendarFromDate(Date date) {
        return newCalendarFromMillisSinceEpochUtc(date.getTime());
    }
    
    /** As {@link #parseCalendar(String)} but returning a {@link Date},
     * (i.e. a record where the time zone has been applied and forgotten). */
    public static Date parseDate(@Nullable String input) {
        if (input==null) return null;
        return parseCalendarMaybe(input).get().getTime();
    }

    /** Parses dates from string, accepting many formats including ISO-8601 and http://yaml.org/type/timestamp.html, 
     * e.g. 2015-06-15 16:00:00 +0000.
     * <p>
     * Millis since epoch (1970) is also supported to represent the epoch (0) or dates in this millenium,
     * but to prevent ambiguity of e.g. "20150615", any other dates prior to the year 2001 are not accepted.
     * (However if a type Long is supplied, e.g. from a YAML parse, it will always be treated as millis since epoch.) 
     * <p>
     * Other formats including locale-specific variants, e.g. recognising month names,
     * are supported but this may vary from platform to platform and may change between versions. */
    public static Calendar parseCalendar(@Nullable String input) {
        if (input==null) return null;
        return parseCalendarMaybe(input).get();
    }
    
    /** as {@link #parseCalendar(String)} but returning a {@link Maybe} rather than throwing or returning null */
    public static Maybe<Calendar> parseCalendarMaybe(@Nullable String input) {
        if (input==null) return Maybe.absent("value is null");
        input = input.trim();
        Maybe<Calendar> result;

        result = parseCalendarUtc(input);
        if (result.isPresent()) return result;

        result = parseCalendarSimpleFlexibleFormatParser(input);
        if (result.isPresent()) return result;
        // return the error from this method
        Maybe<Calendar> returnResult = result;

        result = parseCalendarFormat(input, new SimpleDateFormat(DATE_FORMAT_OF_DATE_TOSTRING, Locale.ROOT));
        if (result.isPresent()) return result;
        result = parseCalendarDefaultParse(input);
        if (result.isPresent()) return result;

        return returnResult;
    }

    @SuppressWarnings("deprecation")
    private static Maybe<Calendar> parseCalendarDefaultParse(String input) {
        try {
            long ms = Date.parse(input);
            if (ms>=new Date(1999, 12, 25).getTime() && ms <= new Date(2200, 1, 2).getTime()) {
                // accept default date parse for this century and next
                GregorianCalendar c = new GregorianCalendar();
                c.setTimeInMillis(ms);
                return Maybe.of((Calendar)c);
            }
        } catch (Exception e) {
            Exceptions.propagateIfFatal(e);
        }
        return Maybe.absent();
    }

    private static Maybe<Calendar> parseCalendarUtc(String input) {
        input = input.trim();
        if (input.matches("\\d+")) {
            if ("0".equals(input)) {
                // accept 0 as epoch UTC
                return Maybe.of(newCalendarFromMillisSinceEpochUtc(0));
            }
            Maybe<Calendar> result = Maybe.of(newCalendarFromMillisSinceEpochUtc(Long.parseLong(input)));
            if (result.isPresent()) {
                int year = result.get().get(Calendar.YEAR);
                if (year >= 2000 && year < 2200) {
                    // only applicable for dates in this century
                    return result;
                } else {
                    return Maybe.absent("long is probably not millis since epoch UTC; millis as string is not in acceptable range");
                }
            }
        }
        return Maybe.absent("not long millis since epoch UTC");
    }

    private final static String DIGIT = "\\d";
    private final static String LETTER = "\\p{L}";
    private final static String COMMON_SEPARATORS = "-\\.";
    private final static String TIME_SEPARATOR = COMMON_SEPARATORS+":";
    private final static String DATE_SEPARATOR = COMMON_SEPARATORS+"/ ";
    private final static String DATE_TIME_ANY_ORDER_GROUP_SEPARATOR = COMMON_SEPARATORS+":/ ";

    private final static String DATE_ONLY_WITH_INNER_SEPARATORS = 
            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) +
            anyChar(DATE_SEPARATOR) +
            namedGroup("month", options(optionally(DIGIT)+DIGIT, anyChar(LETTER)+"+")) +
            anyChar(DATE_SEPARATOR) +
            namedGroup("day", optionally(DIGIT)+DIGIT);
    private final static String DATE_WORDS_2 = 
            namedGroup("month", anyChar(LETTER)+"+") +
            anyChar(DATE_SEPARATOR) +
            namedGroup("day", optionally(DIGIT)+DIGIT) +
            ",?"+anyChar(DATE_SEPARATOR)+"+" +
            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT);
    // we could parse NN-NN-NNNN as DD-MM-YYYY always, but could be confusing for MM-DD-YYYY oriented people, so require month named
    private final static String DATE_WORDS_3 = 
            namedGroup("day", optionally(DIGIT)+DIGIT) +
            anyChar(DATE_SEPARATOR) +
            namedGroup("month", anyChar(LETTER)+"+") +
            ",?"+anyChar(DATE_SEPARATOR)+"+" +
            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT);

    private final static String DATE_ONLY_NO_SEPARATORS = 
            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) +
            namedGroup("month", DIGIT+DIGIT) +
            namedGroup("day", DIGIT+DIGIT);

    private final static String MERIDIAN = anyChar("aApP")+optionally(anyChar("mM"));
    private final static String TIME_ONLY_WITH_INNER_SEPARATORS = 
            namedGroup("hours", optionally(DIGIT)+DIGIT)+
            optionally(
                anyChar(TIME_SEPARATOR)+
                namedGroup("mins", DIGIT+DIGIT)+
                optionally(
                    anyChar(TIME_SEPARATOR)+
                    namedGroup("secs", DIGIT+DIGIT+optionally( optionally("\\.")+DIGIT+"+"))))+
            optionally(" *" + namedGroup("meridian", notMatching(LETTER+LETTER+LETTER)+MERIDIAN));
    private final static String TIME_ONLY_NO_SEPARATORS = 
            namedGroup("hours", DIGIT+DIGIT)+
            namedGroup("mins", DIGIT+DIGIT)+
            optionally(
                namedGroup("secs", DIGIT+DIGIT+optionally( optionally("\\.")+DIGIT+"+")))+
                namedGroup("meridian", "");

    private final static String TZ_CODE = 
            namedGroup("tzCode",
                notMatching(MERIDIAN+options("$", anyChar("^"+LETTER))) + // not AM or PM
                anyChar(LETTER)+"+"+anyChar(LETTER+DIGIT+"\\/\\-\\' _")+"*");
    private final static String TIME_ZONE_SIGNED_OFFSET = 
            namedGroup("tz", 
                options(
                    namedGroup("tzOffset", options("\\+", "-")+
                        DIGIT+optionally(DIGIT)+optionally(optionally(":")+DIGIT+DIGIT)), 
                    optionally("\\+")+TZ_CODE));
    private final static String TIME_ZONE_OPTIONALLY_SIGNED_OFFSET = 
            namedGroup("tz", 
                options(
                    namedGroup("tzOffset", options("\\+", "-", " ")+
                        options("0"+DIGIT, "10", "11", "12")+optionally(optionally(":")+DIGIT+DIGIT)), 
                    TZ_CODE));

    private static String getDateTimeSeparatorPattern(String extraChars) {
        return 
            options(
                " +"+optionally(anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars+",")),
                anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars+",")) +
            anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars)+"*";
    }
    
    @SuppressWarnings("deprecation")
    // we have written our own parsing because the alternatives were either too specific or too general
    // java and apache and even joda-time are too specific, and would require explosion of patterns to be flexible;
    // Natty - https://github.com/joestelmach/natty - is very cool, but it drags in ANTLR,
    // it doesn't support dashes between date and time, and 
    // it encourages relative time which would be awesome but only if we resolved it on read
    // (however there is natty code to parseDateNatty in the git history if we did want to use it)
    private static Maybe<Calendar> parseCalendarSimpleFlexibleFormatParser(String input) {
        input = input.trim();

        String[] DATE_PATTERNS = new String[] {
            DATE_ONLY_WITH_INNER_SEPARATORS,
            DATE_ONLY_NO_SEPARATORS,
            DATE_WORDS_2,
            DATE_WORDS_3,            
        };
        String[] TIME_PATTERNS = new String[] {
            TIME_ONLY_WITH_INNER_SEPARATORS,
            TIME_ONLY_NO_SEPARATORS            
        };
        String[] TZ_PATTERNS = new String[] {
            // space then time zone with sign (+-) or code is preferred
            optionally(getDateTimeSeparatorPattern("")) + " " + TIME_ZONE_SIGNED_OFFSET,
            // then no TZ - but declare the named groups
            namedGroup("tz", namedGroup("tzOffset", "")+namedGroup("tzCode", "")),
            // then any separator then offset with sign
            getDateTimeSeparatorPattern("") + TIME_ZONE_SIGNED_OFFSET,
            
            // try parsing with enforced separators before TZ first 
            // (so e.g. in the case of DATE-0100, the -0100 is the time, not the timezone)
            // then relax below (e.g. in the case of DATE-TIME+0100)
            
            // finally match DATE-TIME-1000 as time zone -1000
            // or DATE-TIME 1000 as TZ +1000 in case a + was supplied but converted to ' ' by web
            // (but be stricter about the format, two or four digits required, and hours <= 12 so as not to confuse with a year)
            optionally(getDateTimeSeparatorPattern("")) + TIME_ZONE_OPTIONALLY_SIGNED_OFFSET
        };
        
        List<String> basePatterns = MutableList.of();
        
        // patterns with date first
        String[] DATE_PATTERNS_UNCLOSED = new String[] {
            // separator before time *required* if date had separators
            DATE_ONLY_WITH_INNER_SEPARATORS + "("+getDateTimeSeparatorPattern("Tt"),
            // separator before time optional if date did not have separators
            DATE_ONLY_NO_SEPARATORS + "("+optionally(getDateTimeSeparatorPattern("Tt")),
            // separator before time required if date has words
            DATE_WORDS_2 + "("+getDateTimeSeparatorPattern("Tt"),
            DATE_WORDS_3 + "("+getDateTimeSeparatorPattern("Tt"),
        };
        for (String tzP: TZ_PATTERNS)
            for (String dateP: DATE_PATTERNS_UNCLOSED)
                for (String timeP: TIME_PATTERNS)
                    basePatterns.add(dateP + timeP+")?" + tzP);
        
        // also allow time first, with TZ after, then before
        for (String tzP: TZ_PATTERNS)
            for (String dateP: DATE_PATTERNS)
                for (String timeP: TIME_PATTERNS)
                    basePatterns.add(timeP + getDateTimeSeparatorPattern("") + dateP + tzP);
        // also allow time first, with TZ after, then before
        for (String tzP: TZ_PATTERNS)
            for (String dateP: DATE_PATTERNS)
                for (String timeP: TIME_PATTERNS)
                    basePatterns.add(timeP + tzP + getDateTimeSeparatorPattern("") + dateP);

        Maybe<Matcher> mm = Maybe.absent();
        for (String p: basePatterns) {
            mm = match(p, input);
            if (mm.isPresent()) break;
        }
        if (mm.isPresent()) {
            Matcher m = mm.get();
            Calendar result;

            String tz = m.group("tz");
            
            int year = Integer.parseInt(m.group("year"));
            int day = Integer.parseInt(m.group("day"));
            
            String monthS = m.group("month");
            int month;
            if (monthS.matches(DIGIT+"+")) {
                month = Integer.parseInt(monthS)-1;
            } else {
                try {
                    month = new SimpleDateFormat("yyyy-MMM-dd", Locale.ROOT).parse("2015-"+monthS+"-15").getMonth();
                } catch (ParseException e) {
                    return Maybe.absent("Unknown date format '"+input+"': invalid month '"+monthS+"'; try http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
                }
            }
            
            if (Strings.isNonBlank(tz)) {
                TimeZone tzz = null;
                String tzCode = m.group("tzCode");
                if (Strings.isNonBlank(tzCode)) {
                    tz = tzCode;
                }
                if (tz.matches(DIGIT+"+")) {
                    // stick a plus in front in case it was submitted by a web form and turned into a space
                    tz = "+"+tz;
                } else {
                    tzz = getTimeZone(tz);
                }
                if (tzz==null) {
                    Maybe<Matcher> tmm = match(" ?(?<tzH>(\\+|\\-||)"+DIGIT+optionally(DIGIT)+")"+optionally(optionally(":")+namedGroup("tzM", DIGIT+DIGIT)), tz);
                    if (tmm.isAbsent()) {
                        return Maybe.absent("Unknown date format '"+input+"': invalid timezone '"+tz+"'; try http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
                    }
                    Matcher tm = tmm.get();
                    String tzM = tm.group("tzM");
                    int offset = (60*Integer.parseInt(tm.group("tzH")) + Integer.parseInt("0"+(tzM!=null ? tzM : "")))*60;
                    tzz = new SimpleTimeZone(offset*1000, tz);
                }
                tz = getTimeZoneOffsetString(tzz, year, month, day);
                result = new GregorianCalendar(tzz);
            } else {
                result = new GregorianCalendar();
            }
            result.clear();
            
            result.set(Calendar.YEAR, year);
            result.set(Calendar.MONTH, month);
            result.set(Calendar.DAY_OF_MONTH, day);
            if (m.group("hours")!=null) {
                int hours = Integer.parseInt(m.group("hours"));
                String meridian = m.group("meridian");
                if (Strings.isNonBlank(meridian) && meridian.toLowerCase().startsWith("p")) {
                    if (hours>12) {
                        return Maybe.absent("Unknown date format '"+input+"': can't be "+hours+" PM; try http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
                    }
                    hours += 12;
                }
                result.set(Calendar.HOUR_OF_DAY, hours);
                String minsS = m.group("mins");
                if (Strings.isNonBlank(minsS)) {
                    result.set(Calendar.MINUTE, Integer.parseInt(minsS));
                }
                String secsS = m.group("secs");
                if (Strings.isBlank(secsS)) {
                    // leave at zero
                } else if (secsS.matches(DIGIT+DIGIT+"?")) {
                    result.set(Calendar.SECOND, Integer.parseInt(secsS));
                } else {
                    double s = Double.parseDouble(secsS);
                    if (secsS.indexOf('.')>=0) {
                        // accept
                    } else if (secsS.length()==5) {
                        // allow ssSSS with no punctuation
                        s = s/=1000;
                    } else {
                        return Maybe.absent("Unknown date format '"+input+"': invalid seconds '"+secsS+"'; try http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
                    }
                    result.set(Calendar.SECOND, (int)s);
                    result.set(Calendar.MILLISECOND, (int)((s*1000) % 1000));
                }
            }
            
            return Maybe.of(result);
        }
        return Maybe.absent("Unknown date format '"+input+"'; try http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
    }
    
    public static TimeZone getTimeZone(String code) {
        if (code.indexOf('/')==-1) {
            if ("Z".equals(code)) return TIME_ZONE_UTC;
            if ("UTC".equals(code)) return TIME_ZONE_UTC;
            if ("GMT".equals(code)) return TIME_ZONE_UTC;
            
            // get the time zone -- most short codes aren't accepted, so accept (and prefer) certain common codes
            if ("EST".equals(code)) return getTimeZone("America/New_York");
            if ("EDT".equals(code)) return getTimeZone("America/New_York");
            if ("PST".equals(code)) return getTimeZone("America/Los_Angeles");
            if ("PDT".equals(code)) return getTimeZone("America/Los_Angeles");
            if ("CST".equals(code)) return getTimeZone("America/Chicago");
            if ("CDT".equals(code)) return getTimeZone("America/Chicago");
            if ("MST".equals(code)) return getTimeZone("America/Denver");
            if ("MDT".equals(code)) return getTimeZone("America/Denver");

            if ("BST".equals(code)) return getTimeZone("Europe/London");  // otherwise BST is Bangladesh!
            if ("CEST".equals(code)) return getTimeZone("Europe/Paris");
            // IST falls through to below, where it is treated as India (not Irish); IDT not recognised
        }
        
        TimeZone tz = TimeZone.getTimeZone(code);
        if (tz!=null && !tz.equals(TimeZone.getTimeZone("GMT"))) {
            // recognized
            return tz;
        }
        // possibly unrecognized -- GMT returned if not known, bad TimeZone API!
        String timeZones[] = TimeZone.getAvailableIDs();
        for (String tzs: timeZones) {
            if (tzs.equals(code)) return tz;
        }
        // definitely unrecognized
        return null;
    }
    
    /** convert a TimeZone e.g. Europe/London to an offset string as at the given day, e.g. +0100 or +0000 depending daylight savings,
     * absent with nice error if zone unknown */
    public static Maybe<String> getTimeZoneOffsetString(String tz, int year, int month, int day) {
        TimeZone tzz = getTimeZone(tz);
        if (tzz==null) return Maybe.absent("Unknown time zone code: "+tz);
        return Maybe.of(getTimeZoneOffsetString(tzz, year, month, day));
    }
    
    /** as {@link #getTimeZoneOffsetString(String, int, int, int)} where the {@link TimeZone} is already instantiated */
    @SuppressWarnings("deprecation")
    public static String getTimeZoneOffsetString(TimeZone tz, int year, int month, int day) {
        int tzMins = tz.getOffset(new Date(year, month, day).getTime())/60/1000;
        String tzStr = (tzMins<0 ? "-" : "+") + Strings.makePaddedString(""+(Math.abs(tzMins)/60), 2, "0", "")+Strings.makePaddedString(""+(Math.abs(tzMins)%60), 2, "0", "");
        return tzStr;
    }

    private static String namedGroup(String name, String pattern) {
        return "(?<"+name+">"+pattern+")";
    }
    private static String anyChar(String charSet) {
        return "["+charSet+"]";
    }
    private static String optionally(String pattern) {
        return "("+pattern+")?";
    }
    private static String options(String ...patterns) {
        return "("+Strings.join(patterns,"|")+")";
    }
    private static String notMatching(String pattern) {
        return "(?!"+pattern+")";
    }
    
    private static Maybe<Matcher> match(String pattern, String input) {
        Matcher m = Pattern.compile("^"+pattern+"$").matcher(input);
        if (m.find()) return Maybe.of(m);
        return Maybe.absent();
    }

    public static Maybe<Calendar> parseCalendarFormat(String dateString, String format) {
        return parseCalendarFormat(dateString, new SimpleDateFormat(format, Locale.ROOT));
    }
    public static Maybe<Calendar> parseCalendarFormat(String dateString, DateFormat format) {
        if (dateString == null) { 
            throw new NumberFormatException("GeneralHelper.parseDateString cannot parse a null string");
        }
        Preconditions.checkNotNull(format, "date format");
        dateString = dateString.trim();
        
        ParsePosition p = new ParsePosition(0);
        Date result = format.parse(dateString, p);
        if (result!=null) {
            // accept results even if the entire thing wasn't parsed, as enough was to match the requested format
            return Maybe.of(newCalendarFromDate(result));
        }
        if (log.isTraceEnabled()) log.trace("Could not parse date "+dateString+" using format "+format+": "+p);
        return Maybe.absent();
    }

    /** removes milliseconds from the date object; needed if serializing to ISO-8601 format 
     * and want to serialize back and get the same data */
    public static Date dropMilliseconds(Date date) {
        return date==null ? null : date.getTime()%1000!=0 ? new Date(date.getTime() - (date.getTime()%1000)) : date;
    }

    /** returns the duration elapsed since the given timestamp (UTC) */
    public static Duration elapsedSince(long timestamp) {
        return Duration.millis(System.currentTimeMillis() - timestamp);
    }
    
    /** true iff it has been longer than the given duration since the given timestamp */
    public static boolean hasElapsedSince(long timestamp, Duration duration) {
        return elapsedSince(timestamp).compareTo(duration) > 0;
    }

    /** more readable and shorter convenience for System.currentTimeMillis() */
    public static long now() {
        return System.currentTimeMillis();
    }
    
}
