package com.neppert.util;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectStreamException;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * An amount of time with a unit. This class takes {@link TimeUnit} the obvious
 * step further and offers a concise way of describing durations.<br>
 * The numeric value of each duration is always specified in its associated
 * unit, thus you cannot assume that the {@link #longValue()} of <i>150 seconds</i>
 * will be equal to the {@link #longValue()} of <i>150000 milliseconds</i>.
 * However, two Duration instances constructed with these arguments will compare
 * {@link #equals(Object) equal} and will also yield identical hash values!<br>
 * Note that you do not really need to know what unit a Duration has in order to
 * use it with APIs that expect milliseconds or nanoseconds:
 *
 * <pre>
 * public void wait(Duration duration) throws InterruptedException {
 *      synchronized (this) {
 *              wait(duration.as(TimeUnit.MILLISECONDS).longValue());
 *      }
 * }
 * </pre>
 */
public final class Duration extends Number implements Comparable<Duration>, Serializable {

        /**
         * The zero duration. Its unit will be {@link TimeUnit#SECONDS}.
         */
        public static final Duration ZERO = new Duration(0, TimeUnit.SECONDS, null);

        /**
         * Compares this duration with another object. Two durations are deemed
         * equal if they represent the same amount of time, regardless of their
         * units.
         *
         * @return <i>true</i> if this duration represent the same amount of time
         *         as the supplied one.
         */
        @Override
        public boolean equals(Object obj) {
                if (obj == this)
                        return true;
                if (!(obj instanceof Duration))
                        return false;
                Duration other = (Duration)obj;
                if (other.basedOn.unit.compareTo(this.basedOn.unit) <= 0)
                        return other.basedOn.unit.convert(this.basedOn.value, this.basedOn.unit) == other.basedOn.value;

                else
                        return this.basedOn.unit.convert(other.basedOn.value, other.basedOn.unit) == this.basedOn.value;

        }

        /**
         * Generates the hash code for this duration. Two durations that represent
         * the same amount of time are guaranteed to yield the same hash code.
         *
         * @return the hash code.
         */
        @Override
        public int hashCode() {
                return hashValue;
        }

        private int computeHash() {
                return Long.valueOf(TimeUnit.SECONDS.convert(basedOn.value, basedOn.unit)).hashCode();
        }

        /**
         * Returns a String representation of this duration. The string consists of
         * the {@link #longValue()} plus a unit identifier. The string will be
         * parseable by {@link #valueOf(String)} if the duration does not have
         * fractional part, i.e. if its {@link #accuracy()} is equal to its
         * {@link #unit()}.
         *
         * @return a String representation of this duration
         */
        @Override
        public String toString() {
                if (this == basedOn)
                        return value + unit2String.get(unit);
                else
                        return doubleValue() + unit2String.get(unit);
        }

        /**
         * Converts the duration to a different unit without losing accuracy.
         * <b>Note:</b> if you convert this duration to a unit that is less
         * granular, its {@link #longValue()} might be less precise the original
         * duration's. But internally, it will still keep the original accuracy,
         * thus it will still be {@link #equals(Object) equal} to the original
         * duration!<br>
         * See {@link #truncateTo(TimeUnit)} for a conversion with loss of accuracy.
         *
         * @param targetUnit
         *            the unit the result will have.
         * @return a duration that compares {@link #equals(Object) equal to} this
         *         unit but has a different unit.
         */
        public Duration as(TimeUnit targetUnit) {
                if (targetUnit == this.unit)
                        return this;
                return new Duration(targetUnit.convert(basedOn.value, basedOn.unit), targetUnit, this.basedOn);
        }

        /**
         * Yields the remainder of this duration in another unit. For newly created
         * durations, the result will always be zero. After conversions involving
         * {@link #as(TimeUnit)} or {@link #add(Duration)} however, the remainder
         * may contain a non-zero value.<br>
         * Suppose you have a duration specified in minutes and want to convert it
         * into hours and remaining minutes:<br>
         *
         * <pre>
         * Duration duration = Duration.valueOf(83, TimeUnit.MINUTES);
         *
         * Duration hours = duration.as(TimeUnit.HOURS); // will have the value 1
         *
         * Duration minutes = hours.fraction(TimeUnit.MINUTES); // will have the value 23
         * </pre>
         *
         * @param targetUnit
         *            the unit the result will have.
         * @return a duration that is the remainder of the precise value of this
         *         duration after being truncated to this unit.
         */
        public Duration fraction(TimeUnit targetUnit) {
                long diff = basedOn.value - basedOn.unit.convert(this.value, this.unit);
                return new Duration(targetUnit.convert(diff, basedOn.unit), targetUnit, new Duration(diff, basedOn.unit, null));

        }

        /**
         * Converts the duration to a different unit, possibly losing accuracy.
         * <b>Note:</b> if you convert the duration to a unit that is less granular
         * the current unit, accuracy might get lost!<br>
         * See {@link #as(TimeUnit)} for a conversion without loss of accuracy.
         *
         * @param targetUnit
         *            the unit the result will have.
         * @return a duration that has the targetUnit.
         */
        public Duration truncateTo(TimeUnit targetUnit) {
                if (targetUnit == this.unit && basedOn == this)
                        return this;
                return new Duration(targetUnit.convert(value, unit), targetUnit, null);
        }

        /**
         * Creates a Duration instance.
         *
         * @param value
         *            the value of the duration.
         * @param unit
         *            the unit used to interpret the value.
         * @return a Duration instance with the supplied value.
         * @throws NullPointerException
         *             if <tt>unit == null</tt>.
         * @throws IllegalArgumentException
         *             if <tt>value &lt; 0</tt>.
         */
        public static Duration valueOf(long value, TimeUnit unit) {
                if (value == 0 && unit == TimeUnit.SECONDS)
                        return ZERO;
                return new Duration(value, unit, null);
        }

        /**
         * Creates a Duration instance.
         *
         * @param value
         *            the value of the duration. It will be parsed using
         *            {@link Long#parseLong(String)}.
         * @param unit
         *            the unit used to interpret the value.
         * @return a Duration instance with the supplied value.
         * @throws NullPointerException
         *             if <tt>unit == null</tt>.
         * @throws NumberFormatException
         *             if value cannot be parsed as a long value.
         */
        public static Duration valueOf(String value, TimeUnit unit) {
                return valueOf(Long.parseLong(value), unit);
        }

        /**
         * Creates a Duration instance.
         *
         * @param value
         *            the value of the duration. It must consist of a sequence
         *            parseable by {@link Long#parseLong(String)} and a unit string
         *            that is either "ns", "&#x00b5;s", "ms" or "s".
         * @return a Duration instance with the supplied value.
         * @throws IllegalArguementException
         *             if value cannot be parsed as a long value or if it contains
         *             no unit.
         */
        public static Duration valueOf(String value) {
                Matcher matcher = DURATION_PATTERN.matcher(value);
                if (!matcher.matches())
                        throw new IllegalArgumentException("invalid duration: " + value);
                return valueOf(matcher.group(1), string2Unit.get(matcher.group(2)));
        }

        /**
         * Creates a Duration instance as the difference of two dates. The unit of
         * the created duration will have the unit {@link TimeUnit#MILLISECONDS}.
         *
         * @param first
         *            the first (a.k.a. earlier Date).
         * @param second
         *            the second (a.k.a. later Date).
         * @return a Duration instance with the unit {@link TimeUnit#MILLISECONDS}
         *         that represents the difference between the two dates.
         * @throws IllegalArguementException
         *             if <tt>second.before(first)</tt>
         */
        public static Duration valueOf(Date first, Date second) {
                if (second.before(first))
                        throw new IllegalArgumentException(second + " is not after " + first);
                long diff = second.getTime() - first.getTime();
                return valueOf(diff, TimeUnit.MILLISECONDS);
        }

        private Duration(long value, TimeUnit unit, Duration basedOn) {
                if (unit == null)
                        throw new NullPointerException("null");
                if (value < 0)
                        throw new IllegalArgumentException("illegal duration: " + value);
                this.unit = unit;
                this.value = value;
                this.basedOn = basedOn == null ? this : basedOn;
                this.hashValue = computeHash();
        }

        /**
         * Returns this duration's value according to the unit. For newly created
         * durations, the result will equal to the value supplied to the constructor
         * and thus the correct duration value. After conversions involving
         * {@link #as(TimeUnit)} or {@link #add(Duration)} however, the value may be
         * different from the integral part as obtained by {@link #longValue()}.
         *
         * @return an approximation of the correct value.
         */
        @Override
        public double doubleValue() {
                if (this == basedOn)
                        return value;
                else {
                        double factor = basedOn.unit.convert(1, unit);
                        return (basedOn.value / factor);
                }
        }

        /**
         * Returns this duration's value according to the unit. For newly created
         * durations, the result will equal to the value supplied to the constructor
         * and thus the correct duration value. After conversions involving
         * {@link #as(TimeUnit)} or {@link #add(Duration)} however, the value may be
         * different from the integral part as obtained by {@link #longValue()}.
         *
         * @return an approximation of the correct value.
         */
        @Override
        public float floatValue() {
                if (this == basedOn)
                        return value;
                else {
                        float factor = basedOn.unit.convert(1, unit);
                        return (basedOn.value / factor);
                }
        }

        /**
         * Returns this duration's value according to the unit. For newly created
         * durations, the result will equal to the value supplied to the constructor
         * and thus the correct duration value. After conversions involving
         * {@link #as(TimeUnit)} or {@link #add(Duration)} however, the value may be
         * different from the correct value. An approximation of the correct value
         * may be obtained through {@link #doubleValue()}. may contain a non-zero
         * value.
         *
         * @return the integral part of the value.
         */
        @Override
        public int intValue() {
                return (int)value;
        }

        /**
         * Returns this duration's value according to the unit. For newly created
         * durations, the result will equal to the value supplied to the constructor
         * and thus the correct duration value. After conversions involving
         * {@link #as(TimeUnit)} or {@link #add(Duration)} however, the value may be
         * different from the correct value. An approximation of the correct value
         * may be obtained through {@link #doubleValue()}. may contain a non-zero
         * value.
         *
         * @return the integral part of the value.
         */
        @Override
        public long longValue() {
                return value;
        }

        /**
         * Returns the unit of this duration. This property determines how to
         * interpret the number returned by one of the <tt>...Value()</tt>
         * methods.<br>
         * Note that the {@link #accuracy()} of this duration may actually be higher
         * than this unit.
         *
         * @return the unit of this duration.
         */
        public TimeUnit unit() {
                return unit;
        }

        /**
         * Adds two durations. The unit of the resulting duration will be the
         * <i>smaller</i> of the two units, a.k.a the unit that is more granular.
         *
         * @param other
         *            the duration to add.
         * @return a duration that holds the sum of this duration and the other
         *         duration.
         */
        public Duration add(Duration other) {
                Duration baseDuration = null;
                if (other.basedOn != other || this.basedOn != this)
                        baseDuration = this.basedOn.add(other.basedOn);
                long newValue;
                TimeUnit newUnit;
                if (other.unit.compareTo(this.unit) <= 0) {
                        newValue = other.unit.convert(this.value, this.unit) + other.value;
                        newUnit = other.unit;
                } else {
                        newValue = this.value + this.unit.convert(other.value, other.unit);
                        newUnit = this.unit;
                }
                return new Duration(newValue, newUnit, baseDuration);
        }

        /**
         * Adds an offset to a duration. The unit of the resulting duration will be
         * the same as this unit.
         *
         * @param diff
         *            the offset to add.
         * @return a duration that holds the sum of this duration and the offset.
         * @throws IllegalArgumentException
         *             if the resulting duration would be negative.
         */
        public Duration add(long diff) {
                long newValue = value + diff;
                if (newValue < 0)
                        throw new IllegalArgumentException("invalid duration: " + newValue);
                long newBasedOnValue = basedOn.value + basedOn.unit.convert(diff, unit);
                if (newValue < 0)
                        throw new IllegalArgumentException("invalid duration: " + newBasedOnValue);
                return new Duration(value + diff, this.unit, new Duration(newBasedOnValue, basedOn.unit, null));
        }

        /**
         * Subtracts two durations. The unit of the resulting duration will be the
         * <i>smaller</i> of the two units, a.k.a the unit that is more granular.
         *
         * @param other
         *            the duration to subtract.
         * @return a duration that holds the difference of this duration and the
         *         other duration.
         * @throws IllegalArgumentException
         *             if the resulting duration would be negative.
         */
        public Duration subtract(Duration other) {
                Duration baseDuration = null;
                if (other.basedOn != other || this.basedOn != this)
                        baseDuration = this.basedOn.subtract(other.basedOn);
                long newValue;
                TimeUnit newUnit;
                if (other.unit.compareTo(this.unit) <= 0) {
                        newValue = other.unit.convert(this.value, this.unit) - other.value;
                        newUnit = other.unit;
                } else {
                        newValue = this.value - this.unit.convert(other.value, other.unit);
                        newUnit = this.unit;
                }
                if (newValue < 0)
                        throw new IllegalArgumentException("invalid duration: " + newValue);
                return new Duration(newValue, newUnit, baseDuration);
        }

        /**
         * Subtracts an offset from a duration. The unit of the resulting duration
         * will be the same as this unit.
         *
         * @param diff
         *            the offset to subtract.
         * @return a duration that holds the difference of this duration and the
         *         offset.
         * @throws IllegalArgumentException
         *             if the resulting duration would be negative.
         */
        public Duration subtract(long diff) {
                return add(-diff);
        }

        /**
         * Returns the accuracy of this duration. For newly created durations, the
         * accuracy will always be equal to the duration's {@link #unit() unit}.<br>
         * After conversions involving {@link #as(TimeUnit)} or
         * {@link #add(Duration)} however, the accuracy may be different.
         *
         * @return the underlying unit that this duration has.
         */
        public TimeUnit accuracy() {
                return basedOn.unit;
        }

        /**
         * Waits for notification using this duration. This duration is converted to
         * milliseconds. The remaining nanoseconds are computed. Both these values
         * are then supplied to {@link Object#wait(long, int)}.
         *
         * @param syncObject
         *            the object to synchronize on.
         * @throws InterruptedException
         *             if the current thread is interrupted.
         */
        public void wait(Object syncObject) throws InterruptedException {
                Duration millis = as(TimeUnit.MILLISECONDS);
                int nanos = millis.fraction(TimeUnit.NANOSECONDS).intValue();
                syncObject.wait(millis.longValue(), nanos);
        }

        /**
         * Joins a thread using this duration. This duration is converted to
         * milliseconds. The remaining nanoseconds are computed. Both these values
         * are then supplied to {@link Thread#join(long, int)}.
         *
         * @param thread
         *            the thread to join with.
         * @throws InterruptedException
         *             if the current thread is interrupted.
         */
        public void join(Thread thread) throws InterruptedException {
                Duration millis = as(TimeUnit.MILLISECONDS);
                int nanos = millis.fraction(TimeUnit.NANOSECONDS).intValue();
                thread.join(millis.longValue(), nanos);
        }

        /**
         * Compares two durations.
         *
         * @param other
         *            the duration to compare this duration with.
         * @return a negative value if this duration is shorter than <tt>other</tt>,
         *         zero if the durations are equal, a positve value otherwise.
         */
        public int compareTo(Duration other) {
                if (other.basedOn.unit.compareTo(this.basedOn.unit) <= 0)
                        return Long.valueOf(other.basedOn.unit.convert(this.basedOn.value, this.basedOn.unit)).compareTo(

                                        other.basedOn.value);
                else
                        return Long.valueOf(this.basedOn.value).compareTo(
                                        this.basedOn.unit.convert(other.basedOn.value, other.basedOn.unit));

        }

        /**
         * Is this duration shorter than another one? (Convenience method that uses
         * {@link #compareTo(Duration)})
         *
         * @param other
         *            the duration to compare this duration with.
         * @return <i>true</i> if this duration is shorter than <tt>other</tt>.
         */
        public boolean isShorter(Duration other) {
                return compareTo(other) < 0;
        }

        /**
         * Is this duration longer than another one? (Convenience method that uses
         * {@link #compareTo(Duration)})
         *
         * @param other
         *            the duration to compare this duration with.
         * @return <i>true</i> if this duration is longer than <tt>other</tt>.
         */
        public boolean isLonger(Duration other) {
                return compareTo(other) > 0;
        }

        private void writeObject(ObjectOutputStream os) throws IOException {
                os.defaultWriteObject();
                if (basedOn == this) {
                        os.writeObject(null);
                } else {
                        os.writeObject(basedOn.unit);
                        os.writeLong(basedOn.value);
                }

        }

        private void readObject(ObjectInputStream is) throws IOException, ClassNotFoundException {
                is.defaultReadObject();
                TimeUnit baseUnit = (TimeUnit)is.readObject();
                if (baseUnit == null)
                        this.basedOn = this;
                else {
                        long baseValue = is.readLong();
                        this.basedOn = new Duration(baseValue, baseUnit, null);
                }
                this.hashValue = computeHash();
        }

        private Object readResolve() throws ObjectStreamException {
                if (basedOn.value == 0 && value == 0 && unit == TimeUnit.SECONDS)
                        return ZERO;
                return this;
        }

        private final static Pattern DURATION_PATTERN = Pattern.compile("(\\d+)\\s*((ns)|(\u00b5s)|(ms)|(s))");

        private static final long serialVersionUID = 239871L;

        private final TimeUnit unit;

        private final long value;

        private transient Duration basedOn;

        private transient int hashValue;

        private final static Map<String, TimeUnit> string2Unit = new HashMap<String, TimeUnit>();

        private final static Map<TimeUnit, String> unit2String = new HashMap<TimeUnit, String>();

        static {
                string2Unit.put("ns", TimeUnit.NANOSECONDS);
                string2Unit.put("\u00b5s", TimeUnit.MICROSECONDS);
                string2Unit.put("ms", TimeUnit.MILLISECONDS);
                string2Unit.put("s", TimeUnit.SECONDS);
                unit2String.put(TimeUnit.NANOSECONDS, "ns");
                unit2String.put(TimeUnit.MICROSECONDS, "\u00b5s");
                unit2String.put(TimeUnit.MILLISECONDS, "ms");
                unit2String.put(TimeUnit.SECONDS, "s");
                try {

                        TimeUnit MINUTES = (TimeUnit)TimeUnit.class.getField("MINUTES").get(null);
                        unit2String.put(MINUTES, "m");
                        string2Unit.put("m", MINUTES);
                        TimeUnit HOURS = (TimeUnit)TimeUnit.class.getField("HOURS").get(null);
                        unit2String.put(HOURS, "h");
                        string2Unit.put("n", HOURS);
                        TimeUnit DAYS = (TimeUnit)TimeUnit.class.getField("DAYS").get(null);
                        unit2String.put(DAYS, "d");
                        string2Unit.put("d", DAYS);

                } catch (Exception e) {
                        // ignore if we're not running Java 6
                }
        }

}

