import { ArgumentError } from '../basic-utilities';
import { DateAdapter } from '../date-adapter';
import { DateTime, dateTimeSortComparer } from '../date-time';
import { IDataContainer, IRunArgs, OccurrenceGenerator } from '../interfaces';
import {
  CollectionIterator,
  ICollectionsArgs,
  IOccurrencesArgs,
  OccurrenceIterator,
} from '../iterators';
import { OccurrenceStream, OperatorFnOutput, pipeFn } from '../operators';
import { DateInput } from '../utilities';

const DATES_ID = Symbol.for('1a872780-b812-4991-9ca7-00c47cfdeeac');

export class Dates<T extends typeof DateAdapter, D = any> extends OccurrenceGenerator<T>
  implements IDataContainer<D> {
  /**
   * Similar to `Array.isArray()`, `isDates()` provides a surefire method
   * of determining if an object is a `Dates` by checking against the
   * global symbol registry.
   */
  static isDates(object: unknown): object is Dates<any> {
    return !!(object && typeof object === 'object' && (object as any)[DATES_ID]);
  }

  get length() {
    return this.adapters.length;
  }

  readonly adapters: ReadonlyArray<InstanceType<T>> = [];

  /** Returns the first occurrence or, if there are no occurrences, null. */
  get firstDate(): InstanceType<T> | null {
    const first = this.adapters[0];

    return (first && (first.set('timezone', this.timezone) as InstanceType<T>)) || null;
  }

  /** Returns the last occurrence or, if there are no occurrences, null. */
  get lastDate(): InstanceType<T> | null {
    const last = this.adapters[this.length - 1];

    return (last && (last.set('timezone', this.timezone) as InstanceType<T>)) || null;
  }

  readonly isInfinite = false;
  readonly hasDuration: boolean;
  readonly maxDuration!: number;
  readonly timezone!: string | null; // set by `OccurrenceGenerator`

  pipe: (...operatorFns: OperatorFnOutput<T>[]) => OccurrenceStream<T> = pipeFn(this);

  /**
   * Convenience property for holding arbitrary data. Accessible on individual DateAdapters
   * generated by this `Dates` object via the `DateAdapter#generators` property. Unlike
   * the rest of the `Dates` object, the data property is mutable.
   */
  data!: D;

  protected readonly [DATES_ID] = true;

  private readonly datetimes: DateTime[] = [];

  constructor(
    args: {
      timezone?: string | null;
      duration?: number;
      dates?: ReadonlyArray<DateInput<T>>;
      data?: D;
      dateAdapter?: T;
    } = {},
  ) {
    super(args);

    this.data = args.data as D;

    if (args.dates) {
      this.adapters = args.dates.map(date => {
        const adapter = this.normalizeDateInputToAdapter(date);

        if (args.duration && adapter.duration !== args.duration) {
          return this.dateAdapter.fromJSON({
            ...adapter.toJSON(),
            duration: args.duration,
          }) as InstanceType<T>;
        }

        return adapter;
      });

      this.datetimes = this.adapters.map(adapter =>
        adapter.set('timezone', this.timezone).toDateTime(),
      );
    }

    this.hasDuration = this.datetimes.every(date => !!date.duration);

    if (this.hasDuration) {
      this.maxDuration = this.adapters.reduce(
        (prev, curr) => (curr.duration! > prev ? curr.duration! : prev),
        0,
      )!;
    }
  }

  occurrences(args: IOccurrencesArgs<T> = {}): OccurrenceIterator<T, [this]> {
    return new OccurrenceIterator(this, this.normalizeOccurrencesArgs(args));
  }

  collections(args: ICollectionsArgs<T> = {}): CollectionIterator<T, [this]> {
    return new CollectionIterator(this, this.normalizeCollectionsArgs(args));
  }

  add(value: DateInput<T>) {
    return new Dates({
      dates: [...this.adapters, value],
      timezone: this.timezone,
      data: this.data,
      dateAdapter: this.dateAdapter,
    });
  }

  remove(value: DateInput<T>) {
    const dates = this.adapters.slice();
    const input = this.normalizeDateInputToAdapter(value);
    const index = dates.findIndex(date => date.valueOf() === input.valueOf());

    if (index >= 0) {
      dates.splice(index, 1);
    }

    return new Dates({
      dates,
      timezone: this.timezone,
      data: this.data,
      dateAdapter: this.dateAdapter,
    });
  }

  /**
   * Dates are immutable. This allows you to create a new `Dates` with the
   * specified property changed.
   *
   * ### Important!
   *
   * When updating `Dates#timezone`, this does not actually change the timezone of the
   * underlying date objects wrapped by this `Dates` instance. Instead, when this `Dates`
   * object is iterated and a specific date is found to be
   * valid, only then is that date converted to the timezone you specify here and returned to
   * you.
   *
   * This distinction might matter when viewing the timezone associated with
   * `Dates#adapters`. If you wish to update the timezone associated with the `date` objects
   * this `Dates` is wrapping, you must update the individual dates themselves by setting
   * the `dates` property.
   *
   */
  set(prop: 'timezone', value: string | null, options?: { keepLocalTime?: boolean }): Dates<T, D>;
  /**
   * Dates are immutable. This allows you to create a new `Dates` with new date objects.
   */
  set(prop: 'dates', value: DateInput<T>[]): Dates<T, D>;
  /**
   * Dates are immutable. This allows you to create a new `Dates` with all of the underlying
   * date objects set to have the specified `duration`. Duration is a length of time,
   * expressed in milliseconds.
   */
  set(prop: 'duration', value: number | undefined): Dates<T, D>;
  set(
    prop: 'timezone' | 'dates' | 'duration',
    value: DateInput<T>[] | string | number | null | undefined,
    options: { keepLocalTime?: boolean } = {},
  ) {
    let timezone = this.timezone;
    let dates: DateInput<T>[] = this.adapters.slice();

    if (prop === 'timezone') {
      if (value === this.timezone && !options.keepLocalTime) return this;
      else if (options.keepLocalTime) {
        dates = this.adapters.map(adapter => {
          const json = adapter.toJSON();
          json.timezone = value as string | null;
          return this.dateAdapter.fromJSON(json);
        });
      }

      timezone = value as string | null;
    } else if (prop === 'dates') {
      dates = value as DateInput<T>[];
    } else if (prop === 'duration') {
      dates = dates.map(date =>
        this.dateAdapter.fromJSON({
          ...(date as InstanceType<T>).toJSON(),
          duration: value as number | undefined,
        }),
      );
    } else {
      throw new ArgumentError(
        `Unexpected prop argument "${prop}". Accepted values are "timezone" or "dates"`,
      );
    }

    return new Dates({
      dates,
      data: this.data,
      dateAdapter: this.dateAdapter,
      timezone,
    });
  }

  filter(
    fn: (date: InstanceType<T>, index: number, array: ReadonlyArray<InstanceType<T>>) => boolean,
  ) {
    return new Dates({
      dates: this.adapters.filter(fn),
      data: this.data,
      dateAdapter: this.dateAdapter,
      timezone: this.timezone,
    });
  }

  /** @internal */
  *_run(args: IRunArgs = {}) {
    let dates = this.datetimes.sort(dateTimeSortComparer);

    if (args.start) {
      dates = dates.filter(date => date.isAfterOrEqual(args.start!));
    }

    if (args.end) {
      dates = dates.filter(date => date.isBeforeOrEqual(args.end!));
    }

    if (args.reverse) {
      dates = dates.slice().reverse();
    }

    if (args.take) {
      dates = dates.slice(0, args.take);
    }

    let dateCache = dates.slice();
    let date = dateCache.shift();
    let yieldArgs: { skipToDate?: DateTime } | undefined;

    while (date) {
      if (yieldArgs) {
        if (
          yieldArgs.skipToDate &&
          (args.reverse ? yieldArgs.skipToDate.isBefore(date) : yieldArgs.skipToDate.isAfter(date))
        ) {
          date = dateCache.shift();
          continue;
        }

        yieldArgs = undefined;
      }

      date.generators.unshift(this);

      yieldArgs = yield this.normalizeRunOutput(date);

      if (yieldArgs && yieldArgs.skipToDate) {
        // need to reset the date cache to allow the same date to be picked again.
        // Also, I suppose it's possible someone might want to go back in time,
        // which this allows.
        dateCache = dates.slice();
      }

      date = dateCache.shift();
    }
  }
}
