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

const CALENDAR_ID = Symbol.for('5e83caab-8318-43d9-bf3d-cb24fe152246');

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

  readonly schedules: ReadonlyArray<IOccurrenceGenerator<T>> = [];

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

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

  readonly isInfinite: boolean;
  readonly hasDuration: boolean;

  protected readonly [CALENDAR_ID] = true;

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

    this.data = args.data as D;

    if (args.schedules) {
      this.schedules = Array.isArray(args.schedules) ? args.schedules : [args.schedules];
      this.schedules = this.schedules.map(schedule => schedule.set('timezone', this.timezone));
    }

    this.isInfinite = this.schedules.some(schedule => schedule.isInfinite);
    this.hasDuration = this.schedules.every(schedule => schedule.hasDuration);
  }

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

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

  set(
    prop: 'timezone',
    value: string | null,
    options?: { keepLocalTime?: boolean },
  ): Calendar<T, D>;
  set(
    prop: 'schedules',
    value: ReadonlyArray<IOccurrenceGenerator<T>> | IOccurrenceGenerator<T>,
  ): Calendar<T, D>;
  set(
    prop: 'timezone' | 'schedules',
    value: ReadonlyArray<IOccurrenceGenerator<T>> | IOccurrenceGenerator<T> | string | null,
    options?: { keepLocalTime?: boolean },
  ) {
    if (prop === 'timezone') {
      return new Calendar({
        schedules: this.schedules.map(schedule =>
          schedule.set(
            prop,
            value as string | null,
            options as { keepLocalTime?: boolean } | undefined,
          ),
        ),
        data: this.data,
        dateAdapter: this.dateAdapter,
        timezone: value as string | null,
      });
    } else if (prop === 'schedules') {
      return new Calendar({
        schedules: Array.isArray(value)
          ? (value as IOccurrenceGenerator<T>[])
          : [value as IOccurrenceGenerator<T>],
        data: this.data,
        dateAdapter: this.dateAdapter,
        timezone: this.timezone,
      });
    }

    throw new ArgumentError('Unknown value for `prop`: ' + `"${prop}"`);
  }

  /** @internal */
  *_run(args: ICollectionsRunArgs = {}): IterableIterator<DateTime> {
    const count = args.take;

    delete args.take;

    let iterator: IterableIterator<DateTime>;

    switch (this.schedules.length) {
      case 0:
        return;
      case 1:
        iterator = this.schedules[0]._run(args);
        break;
      default:
        iterator = new OccurrenceStream({
          operators: [add<T>(...this.schedules)],
          dateAdapter: this.dateAdapter,
          timezone: this.timezone,
        })._run(args);
        break;
    }

    let date = iterator.next().value;
    let index = 0;

    while (date && (count === undefined || count > index)) {
      date.generators.unshift(this);

      const yieldArgs = yield this.normalizeRunOutput(date);

      date = iterator.next(yieldArgs).value;

      index++;
    }
  }
}
