import { Configuration } from '../../configuration/configuration.entity';
import { EConfigurationProperty } from '../../configuration/helpers/configuration-type.helper';

/**
 * Time range type. E.g.: ['08:00','13:00']
 */
type ParsedWorkingHoursDayRange = [string, string];

/**
 * Working hours day type.
 * Can be either range or `null` if the day is off.
 */
type ParsedWorkingHoursDay = ParsedWorkingHoursDayRange[] | null;

/**
 * Class for parsing and working with WorkingHours configuration property
 */
export class WorkingHours {
  /**
   * Parsed working days.
   */
  public workingHours: ParsedWorkingHoursDay[];

  /**
   * Constructs WorkingHours object. Parses string from Configuration for future usage.
   *
   * **Configuration string example:**
   * -;08:00-17:00;+;+;08:00-12:00,13:00-17:00;08:00-17:00;-
   * Explanation:
   * Week starts from Sunday, so it will be as follows:
   * 1: Sunday - modifier "-" = off day
   * 2: Monday - from 08:00 till 17:00
   * 3: Tuesday - modifier "+" = get previous day value - 08:00 till 17:00
   * 4: Wednesday - modifier "+" = get previous day value - 08:00 till 17:00
   * 5: Thursday - 2 ranges: from 08:00 till 12:00 and from 13:00 till 17:00
   * 6: Friday - from 08:00 till 17:00
   * 7: Saturday - modifier "-" = off day
   *
   * **Modifiers:**
   * "+" - use previous day value. (Note that previous day value DOES NOT include off days)
   * "-" - off day.
   *
   * @param workingHours Configuration string
   */
  constructor(workingHours: string) {
    this.parseWorkingHours(workingHours);
  }

  /**
   * Retrieves Configuration property and creates an WorkingHours object
   */
  public static async createFromConfiguration(): Promise<WorkingHours> {
    return new this(
      await Configuration.get(EConfigurationProperty.WORKING_HOURS)
    );
  }

  /**
   * Parses Configuration property string and stores it in `workingHours` property.
   * All modifiers are exchanged for actual values for easier usage.
   * Previous day modifiers are exchanged for actually retrieved ranges
   * Off day modifiers are exchanged for `null`
   *
   * @param workingHours Configuration string
   * @private
   */
  private parseWorkingHours(workingHours: string): void {
    // Split Configuration string by days
    const daySplit = workingHours.split(';');

    // There should be exactly 7 days provided
    if (daySplit.length !== 7) {
      throw new Error(
        'WorkingHours: cannot parse string - exactly 7 days should be provided'
      );
    }

    // Create an array with 7 empty elements
    const days: ParsedWorkingHoursDay[] = new Array(7);

    // Find starting index - algorithm should start from first appearing non-modifier value
    let dayIndex = daySplit.findIndex((d) => d !== '+' && d !== '-');

    // There should be at least 1 actual range provided
    if (dayIndex === -1) {
      throw new Error(
        'WorkingHours: cannot parse string - no actual range provided'
      );
    }

    // Store last actual, non-modifier, day
    let lastDay: ParsedWorkingHoursDay;
    // Loop until whole `days` array is filled with actual values
    while (Object.values(days).length !== days.length) {
      // Retrieve working day
      const day = daySplit[dayIndex];

      if (day === '-') {
        // Store `null` if day is off day modifier
        days[dayIndex] = null;
      } else {
        // Store parsed last day if working day is a not modifier and not off day
        if (day !== '+') {
          // Parse day for storing - split by ranges (`,`) and split each range by start and end value - `-`
          lastDay = this.parseDay(day);
        }

        days[dayIndex] = lastDay;
      }

      // Index increment - should loop over if value `6` (end of week) is reached
      if (dayIndex === 6) {
        dayIndex = 0;
      } else {
        dayIndex++;
      }
    }

    this.workingHours = days;
  }

  /**
   * Validates and parses day string
   * @param day
   * @private
   */
  private parseDay(day: string): ParsedWorkingHoursDay {
    // Split day into ranges and split every range into time strings
    const ranges = day.split(',').map((range) => range.split('-'));

    // Validate each range
    for (const range of ranges) {
      // Each range should contain exactly 2 time values - start and end
      if (range.length !== 2) {
        throw new Error(
          'WorkingHours: cannot parse string - all of the ranges must contain start and end value divided by "-"'
        );
      }

      // Validate each range value - they should in valid time format: 08:00, 10:00, 15:30, etc-etc
      for (const time of range) {
        const match = time.match(/([0-9]{2}):([0-9]{2})/);
        if (match === null || match[0] !== time) {
          throw new Error(
            'WorkingHours: cannot parse string - all of the provided time string must be valid time strings (08:00, 13:13, 15:30, etc)'
          );
        }
      }
    }

    return ranges as ParsedWorkingHoursDay;
  }

  /**
   * Checks whether provided date is active according to stored `workingDays`
   * @param date
   */
  public isDateActive(date: Date): boolean {
    // Get current week day
    const day = this.workingHours[date.getUTCDay()];

    if (day === null) {
      return false;
    }

    return day.some((r) => this.isDateInRange(date, r));
  }

  /**
   * Checks whether provided `date` is in provided `range`
   * @param date
   * @param range
   * @private
   */
  private isDateInRange(
    date: Date,
    range: ParsedWorkingHoursDayRange
  ): boolean {
    // Converts `date` to comparable `number values
    const comparableDate = this.getComparableTimeValue(
      date.getUTCHours(),
      date.getUTCMinutes()
    );

    // Parse time strings
    const rangeStart = this.parseTimeString(range[0]);
    const rangeEnd = this.parseTimeString(range[1]);

    // Convert range start and end values to comparable `number` values
    const comparableRangeStart = this.getComparableTimeValue(
      rangeStart[0],
      rangeStart[1]
    );
    const comparableRangeEnd = this.getComparableTimeValue(
      rangeEnd[0],
      rangeEnd[1]
    );

    return (
      comparableDate >= comparableRangeStart &&
      comparableDate <= comparableRangeEnd
    );
  }

  /**
   * Parses time string into numbers
   * @param timeString - '08:00', '13:00', '15:45', etc-etc
   * @private
   */
  private parseTimeString(timeString: string): [number, number] {
    const split = timeString.split(':');
    return [+split[0], +split[1]];
  }

  /**
   * Converts time `number` values into comparable `number`
   * This is achieved by multiplying hours by 100 and adding minutes to that
   * @param hours
   * @param minutes
   * @private
   */
  private getComparableTimeValue(hours: number, minutes: number): number {
    return hours * 100 + minutes;
  }

  /**
   * Gets next active date from given `date`
   * May not work properly if provided `date` is active
   * @param date
   */
  public getNextActiveDate(date: Date): Date {
    // Get comparable date value
    const comparableDate = this.getComparableTimeValue(
      date.getUTCHours(),
      date.getUTCMinutes()
    );

    // Retrieve starting day index
    const dayStart = date.getUTCDay();

    let dayIncrement = -1;
    let foundRange: ParsedWorkingHoursDayRange = null;

    while (foundRange === null) {
      dayIncrement++;

      // Retrieve current working day - dayStart plus increment with rollover for weeks
      const day = this.workingHours[(dayStart + dayIncrement) % 7];

      // Skip if the day is off
      if (day !== null) {
        // If we are iterating over the next day - we can safely return just the first range
        if (dayIncrement === 0) {
          // Iterate of ranges in the day
          for (const range of day) {
            // Get comparable start value
            const comparableStart = this.getComparableTimeValue(
              ...this.parseTimeString(range[0])
            );
            if (comparableStart > comparableDate) {
              foundRange = range;
              break;
            }
          }
        } else {
          foundRange = day[0];
        }
      }
    }

    // Clone date value
    const nextDate = new Date(date.getTime());
    // Set the new date as an offset from current day until the day we found active
    nextDate.setUTCDate(nextDate.getUTCDate() + dayIncrement);

    // Parse found range start value
    const parsedTime = this.parseTimeString(foundRange[0]);
    nextDate.setUTCHours(parsedTime[0], parsedTime[1], 0, 0);

    return nextDate;
  }
}
