# src/interval.js

    import { Util } from './impl/util';
    import { DateTime } from './datetime';
    import { Duration } from './duration';
    import { Settings } from './settings';
    import { InvalidArgumentError, InvalidIntervalError } from './errors';

    const INVALID = 'Invalid Interval';

    function validateStartEnd(start, end) {
      return !!start && !!end && start.isValid && end.isValid && start <= end;
    }

    /**
     * An Interval object represents a half-open interval of time, where each endpoint is a {@link DateTime}. Conceptually, it's a container for those two endpoints, accompanied by methods for creating, parsing, interrogating, comparing, transforming, and formatting them.
     *
     * Here is a brief overview of the most commonly used methods and getters in Interval:
     *
     * * **Creation** To create an Interval, use {@link fromDateTimes}, {@link after}, {@link before}, or {@link fromISO}.
     * * **Accessors** Use {@link start} and {@link end} to get the start and end.
     * * **Interogation** To analyze the Interval, use {@link count}, {@link length}, {@link hasSame}, {@link contains}, {@link isAfter}, or {@link isBefore}.
     * * **Transformation** To create other Intervals out of this one, use {@link set}, {@link splitAt}, {@link splitBy}, {@link divideEqually}, {@link merge}, {@link xor}, {@link union}, {@link intersection}, or {@link difference}.
     * * **Comparison** To compare this Interval to another one, use {@link equals}, {@link overlaps}, {@link abutsStart}, {@link abutsEnd}, {@link engulfs}
     * * **Output*** To convert the Interval into other representations, see {@link toString}, {@link toISO}, {@link toFormat}, and {@link toDuration}.
     */
    export class Interval {
      /**
       * @private
       */
      constructor(config) {
        Object.defineProperty(this, 's', { value: config.start, enumerable: true });
        Object.defineProperty(this, 'e', { value: config.end, enumerable: true });
        Object.defineProperty(this, 'invalidReason', {
          value: config.invalidReason || null,
          enumerable: false
        });
      }

      /**
       * Create an invalid Interval.
       * @return {Interval}
       */
      static invalid(reason) {
        if (!reason) {
          throw new InvalidArgumentError('need to specify a reason the DateTime is invalid');
        }
        if (Settings.throwOnInvalid) {
          throw new InvalidIntervalError(reason);
        } else {
          return new Interval({ invalidReason: reason });
        }
      }

      /**
       * Create an Interval from a start DateTime and an end DateTime. Inclusive of the start but not the end.
       * @param {DateTime|object|Date} start
       * @param {DateTime|object|Date} end
       * @return {Interval}
       */
      static fromDateTimes(start, end) {
        const builtStart = Util.friendlyDateTime(start),
          builtEnd = Util.friendlyDateTime(end);

        return new Interval({
          start: builtStart,
          end: builtEnd,
          valid: validateStartEnd(builtStart, builtEnd)
        });
      }

      /**
       * Create an Interval from a start DateTime and a Duration to extend to.
       * @param {DateTime|object|Date} start
       * @param {Duration|number|object} duration - the length of the Interval.
       * @return {Interval}
       */
      static after(start, duration) {
        const dur = Util.friendlyDuration(duration),
          dt = Util.friendlyDateTime(start);
        return Interval.fromDateTimes(dt, dt.plus(dur));
      }

      /**
       * Create an Interval from an end DateTime and a Duration to extend backwards to.
       * @param {DateTime|object|Date} end
       * @param {Duration|number|object} duration - the length of the Interval.
       * @return {Interval}
       */
      static before(end, duration) {
        const dur = Util.friendlyDuration(duration),
          dt = Util.friendlyDateTime(end);
        return Interval.fromDateTimes(dt.minus(dur), dt);
      }

      /**
       * Create an Interval from an ISO 8601 string
       * @param {string} string - the ISO string to parse
       * @param {object} opts - options to pass {@see DateTime.fromISO}
       * @return {Interval}
       */
      static fromISO(string, opts) {
        if (string) {
          const [s, e] = string.split(/\//);
          if (s && e) {
            return Interval.fromDateTimes(DateTime.fromISO(s, opts), DateTime.fromISO(e, opts));
          }
        }
        return Interval.invalid('invalid ISO format');
      }

      /**
       * Returns the start of the Interval
       * @return {DateTime}
       */
      get start() {
        return this.isValid ? this.s : null;
      }

      /**
       * Returns the end of the Interval
       * @return {DateTime}
       */
      get end() {
        return this.isValid ? this.e : null;
      }

      /**
       * Returns whether this Interval's end is at least its start, i.e. that the Interval isn't 'backwards'.
       * @return {boolean}
       */
      get isValid() {
        return this.invalidReason === null;
      }

      /**
       * Returns an explanation of why this Interval became invalid, or null if the Interval is valid
       * @return {string}
       */
      get invalidReason() {
        return this.invalidReason;
      }

      /**
       * Returns the length of the Interval in the specified unit.
       * @param {string} unit - the unit (such as 'hours' or 'days') to return the length in.
       * @return {number}
       */
      length(unit = 'milliseconds') {
        return this.isValid ? this.toDuration(...[unit]).get(unit) : NaN;
      }

      /**
       * Returns the count of minutes, hours, days, months, or years included in the Interval, even in part.
       * Unlike {@link length} this counts sections of the calendar, not periods of time, e.g. specifying 'day'
       * asks 'what dates are included in this interval?', not 'how many days long is this interval?'
       * @param {string} [unit='milliseconds'] - the unit of time to count.
       * @return {number}
       */
      count(unit = 'milliseconds') {
        if (!this.isValid) return NaN;
        const start = this.start.startOf(unit),
          end = this.end.startOf(unit);
        return Math.floor(end.diff(start, unit).get(unit)) + 1;
      }

      /**
       * Returns whether this Interval's start and end are both in the same unit of time
       * @param {string} unit - the unit of time to check sameness on
       * @return {boolean}
       */
      hasSame(unit) {
        return this.isValid ? this.e.minus(1).hasSame(this.s, unit) : false;
      }

      /**
       * Return whether this Interval has the same start and end DateTimes.
       * @return {boolean}
       */
      isEmpty() {
        return this.s.valueOf() === this.e.valueOf();
      }

      /**
       * Return this Interval's start is after the specified DateTime.
       * @param {DateTime} dateTime
       * @return {boolean}
       */
      isAfter(dateTime) {
        if (!this.isValid) return false;
        return this.s > dateTime;
      }

      /**
       * Return this Interval's end is before the specified DateTime.
       * @param {Datetime} dateTime
       * @return {boolean}
       */
      isBefore(dateTime) {
        if (!this.isValid) return false;
        return this.e.plus(1) < dateTime;
      }

      /**
       * Return this Interval contains the specified DateTime.
       * @param {DateTime} dateTime
       * @return {boolean}
       */
      contains(dateTime) {
        if (!this.isValid) return false;
        return this.s <= dateTime && this.e > dateTime;
      }

      /**
       * "Sets" the start and/or end dates. Returns a newly-constructed Interval.
       * @param {object} values - the values to set
       * @param {DateTime} values.start - the starting DateTime
       * @param {DateTime} values.end - the ending DateTime
       * @return {Interval}
       */
      set({ start, end } = {}) {
        return Interval.fromDateTimes(start || this.s, end || this.e);
      }

      /**
       * Split this Interval at each of the specified DateTimes
       * @param {...DateTimes} dateTimes - the unit of time to count.
       * @return {[Interval]}
       */
      splitAt(...dateTimes) {
        if (!this.isValid) return [];
        const sorted = dateTimes.map(Util.friendlyDateTime).sort(),
          results = [];
        let s = this.s,
          i = 0;

        while (s < this.e) {
          const added = sorted[i] || this.e,
            next = +added > +this.e ? this.e : added;
          results.push(Interval.fromDateTimes(s, next));
          s = next;
          i += 1;
        }

        return results;
      }

      /**
       * Split this Interval into smaller Intervals, each of the specified length.
       * Left over time is grouped into a smaller interval
       * @param {Duration|number|object} duration - The length of each resulting interval.
       * @return {[Interval]}
       */
      splitBy(duration) {
        if (!this.isValid) return [];
        const dur = Util.friendlyDuration(duration),
          results = [];
        let s = this.s,
          added,
          next;

        while (s < this.e) {
          added = s.plus(dur);
          next = +added > +this.e ? this.e : added;
          results.push(Interval.fromDateTimes(s, next));
          s = next;
        }

        return results;
      }

      /**
       * Split this Interval into the specified number of smaller intervals.
       * @param {number} numberOfParts - The number of Intervals to divide the Interval into.
       * @return {[Interval]}
       */
      divideEqually(numberOfParts) {
        if (!this.isValid) return [];
        return this.splitBy(this.length() / numberOfParts).slice(0, numberOfParts);
      }

      /**
       * Return whether this Interval overlaps with the specified Interval
       * @param {Interval} other
       * @return {boolean}
       */
      overlaps(other) {
        return this.e > other.s && this.s < other.e;
      }

      /**
       * Return whether this Interval's end is adjacent to the specified Interval's start.
       * @param {Interval} other
       * @return {boolean}
       */
      abutsStart(other) {
        if (!this.isValid) return false;
        return +this.e === +other.s;
      }

      /**
       * Return whether this Interval's start is adjacent to the specified Interval's end.
       * @param {Interval} other
       * @return {boolean}
       */
      abutsEnd(other) {
        if (!this.isValid) return false;
        return +other.e === +this.s;
      }

      /**
       * Return whether this Interval engulfs the start and end of the specified Interval.
       * @param {Interval} other
       * @return {boolean}
       */
      engulfs(other) {
        if (!this.isValid) return false;
        return this.s <= other.s && this.e >= other.e;
      }

      /**
       * Return whether this Interval has the same start and end as the specified Interval.
       * @param {Interval} other
       * @return {boolean}
       */
      equals(other) {
        return this.s.equals(other.s) && this.e.equals(other.e);
      }

      /**
       * Return an Interval representing the intersection of this Interval and the specified Interval.
       * Specifically, the resulting Interval has the maximum start time and the minimum end time of the two Intervals.
       * @param {Interval} other
       * @return {Interval}
       */
      intersection(other) {
        if (!this.isValid) return this;
        const s = this.s > other.s ? this.s : other.s,
          e = this.e < other.e ? this.e : other.e;

        if (s > e) {
          return null;
        } else {
          return Interval.fromDateTimes(s, e);
        }
      }

      /**
       * Return an Interval representing the union of this Interval and the specified Interval.
       * Specifically, the resulting Interval has the minimum start time and the maximum end time of the two Intervals.
       * @param {Interval} other
       * @return {Interval}
       */
      union(other) {
        if (!this.isValid) return this;
        const s = this.s < other.s ? this.s : other.s,
          e = this.e > other.e ? this.e : other.e;
        return Interval.fromDateTimes(s, e);
      }

      /**
       * Merge an array of Intervals into a equivalent minimal set of Intervals.
       * Combines overlapping and adjacent Intervals.
       * @param {[Interval]} intervals
       * @return {[Interval]}
       */
      static merge(intervals) {
        const [found, final] = intervals.sort((a, b) => a.s - b.s).reduce(([sofar, current], item) => {
          if (!current) {
            return [sofar, item];
          } else if (current.overlaps(item) || current.abutsStart(item)) {
            return [sofar, current.union(item)];
          } else {
            return [sofar.concat([current]), item];
          }
        }, [[], null]);
        if (final) {
          found.push(final);
        }
        return found;
      }

      /**
       * Return an array of Intervals representing the spans of time that only appear in one of the specified Intervals.
       * @param {[Interval]} intervals
       * @return {[Interval]}
       */
      static xor(intervals) {
        let start = null,
          currentCount = 0;
        const results = [],
          ends = intervals.map(i => [{ time: i.s, type: 's' }, { time: i.e, type: 'e' }]),
          arr = Util.flatten(ends).sort((a, b) => a.time - b.time);

        for (const i of arr) {
          currentCount += i.type === 's' ? 1 : -1;

          if (currentCount === 1) {
            start = i.time;
          } else {
            if (start && +start !== +i.time) {
              results.push(Interval.fromDateTimes(start, i.time));
            }

            start = null;
          }
        }

        return Interval.merge(results);
      }

      /**
       * Return an Interval representing the span of time in this Interval that doesn't overlap with any of the specified Intervals.
       * @param {...Interval} intervals
       * @return {Interval}
       */
      difference(...intervals) {
        return Interval.xor([this].concat(intervals))
          .map(i => this.intersection(i))
          .filter(i => i && !i.isEmpty());
      }

      /**
       * Returns a string representation of this Interval appropriate for debugging.
       * @return {string}
       */
      toString() {
        if (!this.isValid) return INVALID;
        return `[${this.s.toISO()} – ${this.e.toISO()})`;
      }

      /**
       * Returns an ISO 8601-compliant string representation of this Interval.
       * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
       * @param {object} opts - The same options as {@link DateTime.toISO}
       * @return {string}
       */
      toISO(opts) {
        if (!this.isValid) return INVALID;
        return `${this.s.toISO(opts)}/${this.e.toISO(opts)}`;
      }

      /**
       * Returns a string representation of this Interval formatted according to the specified format string.
       * @param {string} dateFormat - the format string. This string formats the start and end time. See {@link DateTime.toFormat} for details.
       * @param {object} opts - options
       * @param {string} [opts.separator =  ' – '] - a separator to place between the start and end representations
       * @return {string}
       */
      toFormat(dateFormat, { separator = ' – ' } = {}) {
        if (!this.isValid) return INVALID;
        return `${this.s.toFormat(dateFormat)}${separator}${this.e.toFormat(dateFormat)}`;
      }

      /**
       * Return a Duration representing the time spanned by this interval.
       * @param {...string} [units=['milliseconds']] - the units (such as 'hours' or 'days') to include in the duration.
       * @example Interval.fromDateTimes(dt1, dt2).toDuration().toObject() //=> { milliseconds: 88489257 }
       * @example Interval.fromDateTimes(dt1, dt2).toDuration('days').toObject() //=> { days: 1.0241812152777778 }
       * @example Interval.fromDateTimes(dt1, dt2).toDuration('hours', 'minutes').toObject() //=> { hours: 24, minutes: 34.82095 }
       * @example Interval.fromDateTimes(dt1, dt2).toDuration('hours', 'minutes', 'seconds').toObject() //=> { hours: 24, minutes: 34, seconds: 49.257 }
       * @example Interval.fromDateTimes(dt1, dt2).toDuration('seconds').toObject() //=> { seconds: 88489.257 }
       * @return {Duration}
       */
      toDuration(...units) {
        if (!this.isValid) {
          return Duration.invalid(this.invalidReason);
        }
        return this.e.diff(this.s, ...units);
      }
    }