import { DateAdapter } from '../date-adapter';
import { DateTime } from '../date-time';
import { IDataContainer, IRunArgs, OccurrenceGenerator } from '../interfaces';
import {
  CollectionIterator,
  ICollectionsArgs,
  IOccurrencesArgs,
  OccurrenceIterator,
} from '../iterators';
import { OccurrenceStream, OperatorFnOutput, pipeFn } from '../operators';
import { RScheduleConfig } from '../rschedule-config';
import { PipeController } from './pipes';
import {
  cloneRuleOptions,
  INormalizedRuleOptions,
  IProvidedRuleOptions,
  normalizeRuleOptions,
} from './rule-options';

const RULE_ID = Symbol.for('c551fc52-0d8c-4fa7-a199-0ac417565b45');

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

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

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

  readonly isInfinite: boolean;

  readonly hasDuration: boolean;

  readonly duration: number | undefined;

  readonly timezone: string | null;

  readonly options: IProvidedRuleOptions<T>;

  protected readonly [RULE_ID] = true;

  private readonly processedOptions!: INormalizedRuleOptions;

  /**
   * Create a new Rule object with the specified rule config and options.
   *
   * ### Options
   *
   * - **timezone**: the timezone that yielded occurrences should be in. Note,
   *   this does not change the rule config. Occurrences are first found using
   *   the unmodified rule config, and then converted to the timezone specified
   *   here before being yielded.
   * - **data**: arbitrary data you can associate with this rule. This
   *   is the only mutable property of `Rule` objects.
   * - **dateAdapter**: the DateAdapter class that should be used for this Rule.
   *
   * ### Rule Config
   *
   * - #### frequency
   *
   *   The frequency rule part identifies the type of recurrence rule. Valid values
   *   include `"SECONDLY"`, `"MINUTELY"`, `"HOURLY"`, `"DAILY"`, `"WEEKLY"`,
   *   `"MONTHLY"`, or `"YEARLY"`.
   *
   * - #### start
   *
   *   The start of the rule (not necessarily the first occurrence).
   *   Either a `DateAdapter` instance, date object, or `DateTime` object.
   *   The type of date object depends on the `DateAdapter` class used for this
   *   `Rule`.
   *
   * - #### end?
   *
   *   The end of the rule (not necessarily the last occurrence).
   *   Either a `DateAdapter` instance, date object, or `DateTime` object.
   *   The type of date object depends on the `DateAdapter` class used for this
   *   `Rule`.
   *
   * - #### duration?
   *
   *   A length of time expressed in milliseconds.
   *
   * - #### interval?
   *
   *   The interval rule part contains a positive integer representing at
   *   which intervals the recurrence rule repeats. The default value is
   *   `1`, meaning every second for a SECONDLY rule, every minute for a
   *   MINUTELY rule, every hour for an HOURLY rule, every day for a
   *   DAILY rule, every week for a WEEKLY rule, every month for a
   *   MONTHLY rule, and every year for a YEARLY rule. For example,
   *   within a DAILY rule, a value of `8` means every eight days.
   *
   * - #### count?
   *
   *   The count rule part defines the number of occurrences at which to
   *   range-bound the recurrence. `count` and `end` are both two different
   *   ways of specifying how a recurrence completes.
   *
   * - #### weekStart?
   *
   *   The weekStart rule part specifies the day on which the workweek starts.
   *   Valid values are `"MO"`, `"TU"`, `"WE"`, `"TH"`, `"FR"`, `"SA"`, and `"SU"`.
   *   This is significant when a WEEKLY rule has an interval greater than 1,
   *   and a `byDayOfWeek` rule part is specified. The
   *   default value is `"MO"`.
   *
   * - #### bySecondOfMinute?
   *
   *   The bySecondOfMinute rule part expects an array of seconds
   *   within a minute. Valid values are 0 to 60.
   *
   * - #### byMinuteOfHour?
   *
   *   The byMinuteOfHour rule part expects an array of minutes within an hour.
   *   Valid values are 0 to 59.
   *
   * - #### byHourOfDay?
   *
   *   The byHourOfDay rule part expects an array of hours of the day.
   *   Valid values are 0 to 23.
   *
   * - #### byDayOfWeek?
   *
   *   *note: the byDayOfWeek rule part is kinda complex. Blame the ICAL spec.*
   *
   *   The byDayOfWeek rule part expects an array. Each array entry can
   *   be a day of the week (`"SU"`, `"MO"` , `"TU"`, `"WE"`, `"TH"`,
   *   `"FR"`, `"SA"`). If the rule's `frequency` is either MONTHLY or YEARLY,
   *   Any entry can also be a tuple where the first value of the tuple is a
   *   day of the week and the second value is an positive/negative integer
   *   (e.g. `["SU", 1]`). In this case, the number indicates the nth occurrence of
   *   the specified day within the MONTHLY or YEARLY rule.
   *
   *   The behavior of byDayOfWeek changes depending on the `frequency`
   *   of the rule.
   *
   *   Within a MONTHLY rule, `["MO", 1]` represents the first Monday
   *   within the month, whereas `["MO", -1]` represents the last Monday
   *   of the month.
   *
   *   Within a YEARLY rule, the numeric value in a byDayOfWeek tuple entry
   *   corresponds to an offset within the month when the byMonthOfYear rule part is
   *   present, and corresponds to an offset within the year otherwise.
   *
   *   Regardless of rule `frequency`, if a byDayOfWeek entry is a string
   *   (rather than a tuple), it means "all of these days" within the specified
   *   frequency (e.g. within a MONTHLY rule, `"MO"` represents all Mondays within
   *   the month).
   *
   * - #### byDayOfMonth?
   *
   *   The byDayOfMonth rule part expects an array of days
   *   of the month. Valid values are 1 to 31 or -31 to -1.
   *
   *   For example, -10 represents the tenth to the last day of the month.
   *   The byDayOfMonth rule part *must not* be specified when the rule's
   *   `frequency` is set to WEEKLY.
   *
   * - #### byMonthOfYear?
   *
   *   The byMonthOfYear rule part expects an array of months
   *   of the year. Valid values are 1 to 12.
   *
   */
  constructor(
    config: IProvidedRuleOptions<T>,
    options: { data?: D; dateAdapter?: T; timezone?: string | null; maxDuration?: number } = {},
  ) {
    super(options);

    this.options = cloneRuleOptions(config);

    if (RScheduleConfig.Rule.defaultWeekStart && !this.options.weekStart) {
      this.options.weekStart = RScheduleConfig.Rule.defaultWeekStart;
    }

    this.processedOptions = normalizeRuleOptions(this.dateAdapter, this.options);
    this.timezone =
      options.timezone !== undefined ? options.timezone : this.processedOptions.start.timezone;
    this.data = options.data as D;
    this.hasDuration = !!config.duration;

    if (this.hasDuration) this.duration = config.duration;

    this.isInfinite =
      this.processedOptions.end === undefined && this.processedOptions.count === undefined;
  }

  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));
  }

  /**
   * Rule's are immutable. This allows you to create a new Rule with an updated timezone
   * or rule option.
   *
   * ### Important!
   * When updating the rule's timezone, this does not change the *options* associated with this
   * `Rule`, so the rule is still processed using whatever timezone is
   * associated with the rule's `start` time. When the rule is run, and
   * a date is found to be valid, that date is only then converted to
   * the timezone you specify here and returned to you. If you wish
   * to update the timezone associated with the rule options, change the rule's
   * `start` time.
   */
  set(prop: 'timezone', value: string | null, tzoptions?: { keepLocalTime?: boolean }): Rule<T, D>;
  set(prop: 'options', value: IProvidedRuleOptions<T>): Rule<T, D>;
  set<O extends keyof IProvidedRuleOptions<T>>(
    prop: O,
    value: IProvidedRuleOptions<T>[O],
  ): Rule<T, D>;
  set<O extends keyof IProvidedRuleOptions<T> | 'timezone' | 'options'>(
    prop: O,
    value: IProvidedRuleOptions<T>[Exclude<O, 'timezone' | 'options'>] | string | null,
    tzoptions: { keepLocalTime?: boolean } = {},
  ) {
    let options = cloneRuleOptions(this.options);
    let timezone = this.timezone;

    if (prop === 'timezone') {
      if (value === this.timezone && !tzoptions.keepLocalTime) return this;
      else if (tzoptions.keepLocalTime) {
        const json = this.normalizeDateInput(options.start).toJSON();
        json.timezone = value as string | null;
        const adapter = this.dateAdapter.fromJSON(json);

        // prettier-ignore
        options.start =
          this.dateAdapter.isInstance(options.start) ? adapter :
          DateTime.isInstance(options.start) ? adapter.toDateTime() :
          adapter.date;
      }

      timezone = value as string | null;
    } else if (prop === 'options') {
      options = value as IProvidedRuleOptions<T>;
    } else {
      options[prop as Exclude<O, 'timezone' | 'options'>] = value as IProvidedRuleOptions<
        T
      >[Exclude<O, 'timezone' | 'options'>];
    }

    return new Rule(options, {
      data: this.data,
      dateAdapter: this.dateAdapter,
      timezone,
    });
  }

  /**  @internal use `occurrences()` instead */
  *_run(rawArgs: IRunArgs = {}): IterableIterator<DateTime> {
    const args = this.normalizeRunArgs(rawArgs);

    const controller = new PipeController(this.processedOptions, args);

    const iterator = controller._run();

    let date = iterator.next().value;

    let index = 0;

    while (date && (args.take === undefined || index < args.take)) {
      index++;

      date.generators.unshift(this);

      const yieldArgs = yield this.normalizeRunOutput(date);

      date = iterator.next(yieldArgs).value;
    }
  }
}
