import * as moment from 'moment';

type Moment = moment.Moment;

const millisPerSecond = 1000;
const millisPerMinute = millisPerSecond * 60;
const millisPerHour = millisPerMinute * 60;
const millisPerDay = millisPerHour * 24;
const millisPerWeek = millisPerDay * 7;
const millisPerYear = millisPerDay * 365;


let recurrenceRegex = /^R(\d*)/;
let recurrenceIndex = {
  repetitions: 1
};
// From https://gist.github.com/philipashlock/8830168
let dateRegex = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
let durationRegex = /^(R(\d*)?\/)?P(?:(\d+(?:\.\d+)?)?Y)?(?:(\d+(?:\.\d+)?)?M)?(?:(\d+(?:\.\d+)?)?W)?(?:(\d+(?:\.\d+)?)?D)?(?:T(?:(\d+(?:\.\d+)?)?H)?(?:(\d+(?:\.\d+)?)?M)?(?:(\d+(?:\.\d+)?)?S)?)?$/;
let durationIndex = {
  hasRecurrence:   0,
  repetitionCount: 1,
  year:            2,
  month:           3,
  week:            4,
  day:             5,
  hour:            6,
  minute:          7,
  second:          8
};
let rangeDateDateRegex = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?(\/)([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
let rangeDateDurationRegex = /^(R\d*\/)?([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\4([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\18[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?(\/)P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/;
let rangeDurationDateRegex = /^(R\d*\/)?P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?\/([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\4([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\18[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;

function isInfinite(s: string) {
  return s.length === 0;
}

function isDuration(s: string) {
  return s[0] === 'P';
}

function isRepeating(s: string) {
  return s[0] === 'R';
}

function isTime(s: string) {
  return moment(s).isValid();
}

enum String8601Type {
  repeating,
  time,
  duration,
  infinite
}

interface parsed8601String {
  type: String8601Type;
  value: any;
}

function parse8601String(s: string): parsed8601String {
  if (isInfinite(s)) {
    return {
      type: String8601Type.infinite,
      value: null
    };
  } else if (isDuration(s)) {
    return {
      type:  String8601Type.duration,
      value: moment.duration(s)
    };
  } else if (isRepeating(s)) {
    let result = recurrenceRegex.exec(s);
    return {
      type:  String8601Type.repeating,
      value: typeof result[recurrenceIndex.repetitions] === 'string' ?
               parseInt(result[recurrenceIndex.repetitions]) : Number.POSITIVE_INFINITY
    };
  } else if (isTime(s)) {
    return {
      type:  String8601Type.time,
      value: moment(s).valueOf()
    };
  } else {
    throw new Error(`[${s}] Is not a valid part of an ISO8601 time string`);
  }
}

/**
 * Class to describe ISO 8601 time intervals, including the repeating functionality.
 *
 * The class is primarily backed by moment.js and contains the start, end, duration,
 * and number of repetitions found in the ISO8601 string or overridden in the constructor.
 * Typical usage of the class is to construct one with a ISO8601 string in the constructor.
 */
export class Interval {

  // Start interval component as unix timestamp (ms)
  private _start: number;
  // End interval component as unix timestamp (ms)
  private _end: number;
  // Duration component (ms)
  private _duration: moment.Duration;
  private _recurs: boolean;
  // 0 if no repetition, Number.POSITIVE_INFINITY if infinite repetition
  private _repetitions: number;

  // The index of the first occurrence
  private _first: number;
  // The index of the last occurrence
  private _last: number;

  // This is set to true if the span is of the form '/{8601 string}' or '{8601 string}/' denoting a single never
  // ending span
  private _infiniteSpan: boolean;

  /**
   * The start of all intervals, if this is a repeating interval
   * then this is the start of the first repitition.
   * Returns an invalid moment if there are infinite negative repetitions
   */
  get start(): moment.Moment {
    if (this.isInfiniteNegative) {
      return moment.invalid();
    } else {
      return this.occurrence(this.first);
    }
  }

  /**
   * The end of all intervals, if this is a repeating interval
   * then this is then end of the last repitition.
   * Returns an invalid moment if there are infinite positive repetitions
   */
  get end(): moment.Moment {
    if (this.isInfinitePositive) {
      return moment.invalid();
    } else {
      return this.occurrence(this.last);
    }
  }

  /**
   * The duration of a single repition of the interval
   */
  get duration(): moment.Duration {
    if (this._duration) {
      return this._duration;
    }
    if (this._start && this._end) {
      return moment.duration(this._end - this._start);
    } else {
      return moment.duration(0);
    }
  }

  /**
   * True if the schedule is infintely long
   * @returns {boolean}
   */
  get isInfinite(): boolean {
    return !isFinite(this._repetitions) || this._infiniteSpan;
  }

  /**
   * True if the schedule progresses infinitely in the positive
   * @returns {boolean}
   */
  get isInfinitePositive(): boolean {
    return !isFinite(this.last) || (this._infiniteSpan && !this._end);
  }

  /**
   * True if the schedule progresses infinitely in the negative
   * @returns {boolean}
   */
  get isInfiniteNegative(): boolean {
    return !isFinite(this.first) || (this._infiniteSpan && !this._start);
  }

  /**
   * True if the schedule repeats (has more than one occurrence)
   * @returns {boolean}
   */
  get isRepeating(): boolean {
    return this._recurs;
  }

  /**
   * The number of repetitions
   * @returns {number}
   */
  get repetitions(): number {
    return this._repetitions;
  }

  /**
   * Index of the first occurrence,
   * Number.NEGATIVE_INFINITY if the interval is reverse repeating indefinitely
   * @returns {number}
   */
  get first(): number {
    return this._first;
  }

  /**
   * Index of the last occurrence,
   * Number.POSITIVE_INFINITY if the interval is forward repeating indefinitely
   * @returns {number}
   */
  get last(): number {
    return this._last;
  }

  /**
   * @param interval either ISO8601 string or an instance to copy
   * @param repetitions the number of times to repeat, null for infinite
   */
  constructor(interval?: string | Interval, repetitions?: number) {
    this._recurs = false;
    this._repetitions = 0;
    this._infiniteSpan = false;

    if (typeof interval === 'string') {
      // Parse out the ISO 8601 string
      let split = interval.split('/');
      if (split.length > 3) {
        throw new Error(`Invalid ISO 8601 string[${interval}]`);
      }

      for (let i = 0; i < split.length; i++) {
        let fragment = split[i];
        let parsed = parse8601String(fragment);

        switch (parsed.type) {

          case String8601Type.repeating:
            if (i !== 0) {
              throw new Error(`Incorrect placement of recurrence`);
            }
            if (parsed.value !== 0) {
              this._recurs = true;
              this._repetitions = parsed.value;
            }
            break;

          case String8601Type.time:
            if (this._duration || this._start || this._infiniteSpan) {
              this._end = parsed.value;
            } else if (!this._start) {
              this._start = parsed.value;
            } else {
              throw new Error(`Invalid interval[${interval}] end time must come after a duration, start time or be an infinite span`);
            }
            break;

          case String8601Type.duration:
            if (this._duration) {
              throw new Error(`Invalid interval[${interval}] two durations found`);
            }
            this._duration = parsed.value;
            break;

          case String8601Type.infinite:
            if (this._duration || this._recurs) {
              throw new Error(`Invalid interval[${interval}] single span infinite not compatible with durations or recurrence`);
            }
            this._infiniteSpan = true;
            break;

          default:
            throw new Error(`Experienced unhandled type`);
        }
      }
    } else {
      this._start = interval._start;
      this._end = interval._end;
      this._duration = moment.duration(interval._duration);
      this._recurs = interval._recurs;
      this._repetitions = interval._repetitions;
      this._infiniteSpan = interval._infiniteSpan;
    }

    if (typeof repetitions === 'number') {
      if (repetitions === 0) {
        this._recurs = false;
      }
      this._repetitions = repetitions;
    } else if (repetitions === null) {
      this._recurs = true;
      this._repetitions = repetitions;
    }


    // Throw an error if this this is just bunk
    if (this._start && this._end && this._duration) {
      throw new Error(`Error creating interval with args[${arguments}]`);
    }

    // Determine the first and last occurrence indexes
    if (!this._recurs) {
      // Non recurring case
      this._first = 0;
      if (this._end || this._duration) {
        this._last = 1;
      } else {
        this._last = 0;
      }
    } else if (this._start && (this._end || this._duration)) {
      // Forward progressing case
      this._first = 0;
      this._last = this._repetitions;
    } else {
      // Backward progressing case
      this._last = 0;
      this._first = -this._repetitions;
    }
  }

  public occurrence(idx: number): moment.Moment {
    if (idx > this.last || idx < this.first) {
      return moment.invalid();
    }
    if (this.isInfiniteNegative) {
      return moment(this._end).subtract(this.durationBetween(idx, this.last));
    } else {
      return moment(this._start).add(this.durationBetween(this.first, idx));
    }
  }

  /**
   * Get a list of the occurrences for an interval,
   * if no parameters are supplied then all occurrences are returned.
   *
   * @param {number} [from] the starting occurrence index
   * @param {number} [to] the ending occurrence index
   * @returns {moment.Moment[]}
   */
  public slice(from?: number, to?: number): moment.Moment[] {
    // Check for being out of bounds
    if (from > this.last || to < this.first) {
      return [];
    }

    if (from == null) {
      if (this.isInfiniteNegative) {
        throw new Error(`Tried to get all occurrences with no lower bound[${this.toISOString()}]`);
      }
      from = this.first;
    } else if (from < this.first) {
      from = this.first;
    }

    if (to == null) {
      if (this.isInfinitePositive) {
        throw new Error(`Tried to get all occurrences with no upper bound[${this.toISOString()}]`);
      }
      to = this.last;
    } else if (to > this.last ) {
      to = this.last;
    }

    let count = to - from + 1;
    let initialDuration = this.durationBetween(from, to);
    let currentOccurrence;
    if (this.isInfiniteNegative) {
      currentOccurrence = moment(this._end).subtract(initialDuration);
    } else {
      currentOccurrence = moment(this._start).add(initialDuration);
    }
    let occurrences = [];
    for (var i = 0; i < count; i++) {
      occurrences.push(currentOccurrence);
      currentOccurrence.add(this.duration);
    }
    return occurrences;
  }

  /**
   * Get the recurrence (the index of the occurrence) after the supplied time
   * @param after Moment or something that it parses (if a number, then in epoc ms)
   */
  public indexAfter(after: string | number | moment.Moment): number {
    let afterMs = moment(after).valueOf();
    let deltaMs;
    if (this._start) {
      deltaMs = afterMs - this._start;
    } else {
      deltaMs = afterMs - this._end;
    }
    let durationMs = this.duration.asMilliseconds();
    return Math.ceil(deltaMs / durationMs);
  }

  public indexBefore(before: string | number | moment.Moment) {
    return this.indexAfter(before) - 1;
  }

  /**
   * Get the occurrence happening after the supplied date.
   * Throws Error if there is no occurrence after the supplied date
   */
  public occurrenceAfter(after: string | number | moment.Moment): moment.Moment {
    let afterMoment = moment(after);
    let recurrence = this.indexAfter(afterMoment);
    return this.occurrence(recurrence);
  }

  public durationBetween(from: number, to: number): moment.Duration {
    return moment.duration(this.duration.asMilliseconds() * (to - from));
  }

  static extents(intervalList: Interval[]): Interval {
    let start = moment.min(intervalList.map(s => s.start));
    let end = moment.max(intervalList.map(s => s.end));
    return new Interval(`${start.isValid() ? start.toISOString() : ''}/${end.isValid() ? end.toISOString() : ''}`);
  }

  toISOString(): string {
    let parts = [];
    if (this._recurs) {
      parts.push(`R${isFinite(this._repetitions) ? this._repetitions.toString() : ''}`);
    }
    if (this._start) {
      parts.push(moment(this._start).toISOString());
      if (this._infiniteSpan) {
        parts.push('');
      }
    }
    if (this._duration) {
      parts.push(this._duration.toISOString());
    }
    if (this._end) {
      parts.push(moment(this._end).toISOString());
      if (this._infiniteSpan) {
        parts.push('');
      }
    }
    return parts.join('/');
  }

  toString(): string {
    return this.toISOString();
  }
}