"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronExpression = exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE = exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE = void 0;
const CronDate_1 = require("./CronDate");
/**
 * Error message for when the current date is outside the specified time span.
 */
exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE = 'Out of the time span range';
/**
 * Error message for when the loop limit is exceeded during iteration.
 */
exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE = 'Invalid expression, loop limit exceeded';
/**
 * Cron iteration loop safety limit
 */
const LOOP_LIMIT = 10000;
/**
 * Class representing a Cron expression.
 */
class CronExpression {
    #options;
    #tz;
    #currentDate;
    #startDate;
    #endDate;
    #fields;
    /**
     * Creates a new CronExpression instance.
     *
     * @param {CronFieldCollection} fields - Cron fields.
     * @param {CronExpressionOptions} options - Parser options.
     */
    constructor(fields, options) {
        this.#options = options;
        this.#tz = options.tz;
        this.#startDate = options.startDate ? new CronDate_1.CronDate(options.startDate, this.#tz) : null;
        this.#endDate = options.endDate ? new CronDate_1.CronDate(options.endDate, this.#tz) : null;
        let currentDateValue = options.currentDate ?? options.startDate;
        if (currentDateValue) {
            const tempCurrentDate = new CronDate_1.CronDate(currentDateValue, this.#tz);
            if (this.#startDate && tempCurrentDate.getTime() < this.#startDate.getTime()) {
                currentDateValue = this.#startDate;
            }
            else if (this.#endDate && tempCurrentDate.getTime() > this.#endDate.getTime()) {
                currentDateValue = this.#endDate;
            }
        }
        this.#currentDate = new CronDate_1.CronDate(currentDateValue, this.#tz);
        this.#fields = fields;
    }
    /**
     * Getter for the cron fields.
     *
     * @returns {CronFieldCollection} Cron fields.
     */
    get fields() {
        return this.#fields;
    }
    /**
     * Converts cron fields back to a CronExpression instance.
     *
     * @public
     * @param {Record<string, number[]>} fields - The input cron fields object.
     * @param {CronExpressionOptions} [options] - Optional parsing options.
     * @returns {CronExpression} - A new CronExpression instance.
     */
    static fieldsToExpression(fields, options) {
        return new CronExpression(fields, options || {});
    }
    /**
     * Checks if the given value matches any element in the sequence.
     *
     * @param {number} value - The value to be matched.
     * @param {number[]} sequence - The sequence to be checked against.
     * @returns {boolean} - True if the value matches an element in the sequence; otherwise, false.
     * @memberof CronExpression
     * @private
     */
    static #matchSchedule(value, sequence) {
        return sequence.some((element) => element === value);
    }
    /**
     * Determines if the current date matches the last specified weekday of the month.
     *
     * @param {Array<(number|string)>} expressions - An array of expressions containing weekdays and "L" for the last weekday.
     * @param {CronDate} currentDate - The current date object.
     * @returns {boolean} - True if the current date matches the last specified weekday of the month; otherwise, false.
     * @memberof CronExpression
     * @private
     */
    static #isLastWeekdayOfMonthMatch(expressions, currentDate) {
        const isLastWeekdayOfMonth = currentDate.isLastWeekdayOfMonth();
        return expressions.some((expression) => {
            // The first character represents the weekday
            const weekday = parseInt(expression.toString().charAt(0), 10) % 7;
            if (Number.isNaN(weekday)) {
                throw new Error(`Invalid last weekday of the month expression: ${expression}`);
            }
            // Check if the current date matches the last specified weekday of the month
            return currentDate.getDay() === weekday && isLastWeekdayOfMonth;
        });
    }
    /**
     * Find the next scheduled date based on the cron expression.
     * @returns {CronDate} - The next scheduled date or an ES6 compatible iterator object.
     * @memberof CronExpression
     * @public
     */
    next() {
        return this.#findSchedule();
    }
    /**
     * Find the previous scheduled date based on the cron expression.
     * @returns {CronDate} - The previous scheduled date or an ES6 compatible iterator object.
     * @memberof CronExpression
     * @public
     */
    prev() {
        return this.#findSchedule(true);
    }
    /**
     * Check if there is a next scheduled date based on the current date and cron expression.
     * @returns {boolean} - Returns true if there is a next scheduled date, false otherwise.
     * @memberof CronExpression
     * @public
     */
    hasNext() {
        const current = this.#currentDate;
        try {
            this.#findSchedule();
            return true;
        }
        catch {
            return false;
        }
        finally {
            this.#currentDate = current;
        }
    }
    /**
     * Check if there is a previous scheduled date based on the current date and cron expression.
     * @returns {boolean} - Returns true if there is a previous scheduled date, false otherwise.
     * @memberof CronExpression
     * @public
     */
    hasPrev() {
        const current = this.#currentDate;
        try {
            this.#findSchedule(true);
            return true;
        }
        catch {
            return false;
        }
        finally {
            this.#currentDate = current;
        }
    }
    /**
     * Iterate over a specified number of steps and optionally execute a callback function for each step.
     * @param {number} steps - The number of steps to iterate. Positive value iterates forward, negative value iterates backward.
     * @returns {CronDate[]} - An array of iterator fields or CronDate objects.
     * @memberof CronExpression
     * @public
     */
    take(limit) {
        const items = [];
        if (limit >= 0) {
            for (let i = 0; i < limit; i++) {
                try {
                    items.push(this.next());
                }
                catch {
                    return items;
                }
            }
        }
        else {
            for (let i = 0; i > limit; i--) {
                try {
                    items.push(this.prev());
                }
                catch {
                    return items;
                }
            }
        }
        return items;
    }
    /**
     * Reset the iterators current date to a new date or the initial date.
     * @param {Date | CronDate} [newDate] - Optional new date to reset to. If not provided, it will reset to the initial date.
     * @memberof CronExpression
     * @public
     */
    reset(newDate) {
        this.#currentDate = new CronDate_1.CronDate(newDate || this.#options.currentDate);
    }
    /**
     * Generate a string representation of the cron expression.
     * @param {boolean} [includeSeconds=false] - Whether to include the seconds field in the string representation.
     * @returns {string} - The string representation of the cron expression.
     * @memberof CronExpression
     * @public
     */
    stringify(includeSeconds = false) {
        return this.#fields.stringify(includeSeconds);
    }
    /**
     * Check if the cron expression includes the given date
     * @param {Date|CronDate} date
     * @returns {boolean}
     */
    includesDate(date) {
        const { second, minute, hour, month } = this.#fields;
        const dt = new CronDate_1.CronDate(date, this.#tz);
        // Check basic time fields first
        if (!second.values.includes(dt.getSeconds()) ||
            !minute.values.includes(dt.getMinutes()) ||
            !hour.values.includes(dt.getHours()) ||
            !month.values.includes((dt.getMonth() + 1))) {
            return false;
        }
        // Check day of month and day of week using the same logic as #findSchedule
        if (!this.#matchDayOfMonth(dt)) {
            return false;
        }
        // Check nth day of week if specified
        if (this.#fields.dayOfWeek.nthDay > 0) {
            const weekInMonth = Math.ceil(dt.getDate() / 7);
            if (weekInMonth !== this.#fields.dayOfWeek.nthDay) {
                return false;
            }
        }
        return true;
    }
    /**
     * Returns the string representation of the cron expression.
     * @returns {CronDate} - The next schedule date.
     */
    toString() {
        /* istanbul ignore next - should be impossible under normal use to trigger the or branch */
        return this.#options.expression || this.stringify(true);
    }
    /**
     * Determines if the given date matches the cron expression's day of month and day of week fields.
     *
     * The function checks the following rules:
     * Rule 1: If both "day of month" and "day of week" are restricted (not wildcard), then one or both must match the current day.
     * Rule 2: If "day of month" is restricted and "day of week" is not restricted, then "day of month" must match the current day.
     * Rule 3: If "day of month" is a wildcard, "day of week" is not a wildcard, and "day of week" matches the current day, then the match is accepted.
     * If none of the rules match, the match is rejected.
     *
     * @param {CronDate} currentDate - The current date to be evaluated against the cron expression.
     * @returns {boolean} Returns true if the current date matches the cron expression's day of month and day of week fields, otherwise false.
     * @memberof CronExpression
     * @private
     */
    #matchDayOfMonth(currentDate) {
        // Check if day of month and day of week fields are wildcards or restricted (not wildcard).
        const isDayOfMonthWildcardMatch = this.#fields.dayOfMonth.isWildcard;
        const isRestrictedDayOfMonth = !isDayOfMonthWildcardMatch;
        const isDayOfWeekWildcardMatch = this.#fields.dayOfWeek.isWildcard;
        const isRestrictedDayOfWeek = !isDayOfWeekWildcardMatch;
        // Calculate if the current date matches the day of month and day of week fields.
        const matchedDOM = CronExpression.#matchSchedule(currentDate.getDate(), this.#fields.dayOfMonth.values) ||
            (this.#fields.dayOfMonth.hasLastChar && currentDate.isLastDayOfMonth());
        const matchedDOW = CronExpression.#matchSchedule(currentDate.getDay(), this.#fields.dayOfWeek.values) ||
            (this.#fields.dayOfWeek.hasLastChar &&
                CronExpression.#isLastWeekdayOfMonthMatch(this.#fields.dayOfWeek.values, currentDate));
        // Rule 1: Both "day of month" and "day of week" are restricted; one or both must match the current day.
        if (isRestrictedDayOfMonth && isRestrictedDayOfWeek && (matchedDOM || matchedDOW)) {
            return true;
        }
        // Rule 2: "day of month" restricted and "day of week" not restricted; "day of month" must match the current day.
        if (matchedDOM && !isRestrictedDayOfWeek) {
            return true;
        }
        // Rule 3: "day of month" is a wildcard, "day of week" is not a wildcard, and "day of week" matches the current day.
        if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && matchedDOW) {
            return true;
        }
        // If none of the rules match, the match is rejected.
        return false;
    }
    /**
     * Determines if the current hour matches the cron expression.
     *
     * @param {CronDate} currentDate - The current date object.
     * @param {DateMathOp} dateMathVerb - The date math operation enumeration value.
     * @param {boolean} reverse - A flag indicating whether the matching should be done in reverse order.
     * @returns {boolean} - True if the current hour matches the cron expression; otherwise, false.
     */
    #matchHour(currentDate, dateMathVerb, reverse) {
        const currentHour = currentDate.getHours();
        const isMatch = CronExpression.#matchSchedule(currentHour, this.#fields.hour.values);
        const isDstStart = currentDate.dstStart === currentHour;
        const isDstEnd = currentDate.dstEnd === currentHour;
        if (!isMatch && !isDstStart) {
            currentDate.dstStart = null;
            currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Hour, this.#fields.hour.values.length);
            return false;
        }
        if (isDstStart && !CronExpression.#matchSchedule(currentHour - 1, this.#fields.hour.values)) {
            currentDate.invokeDateOperation(dateMathVerb, CronDate_1.TimeUnit.Hour);
            return false;
        }
        if (isDstEnd && !reverse) {
            currentDate.dstEnd = null;
            currentDate.applyDateOperation(CronDate_1.DateMathOp.Add, CronDate_1.TimeUnit.Hour, this.#fields.hour.values.length);
            return false;
        }
        return true;
    }
    /**
     * Validates the current date against the start and end dates of the cron expression.
     * If the current date is outside the specified time span, an error is thrown.
     *
     * @param currentDate {CronDate} - The current date to validate.
     * @throws {Error} If the current date is outside the specified time span.
     * @private
     */
    #validateTimeSpan(currentDate) {
        if (!this.#startDate && !this.#endDate) {
            return;
        }
        const currentTime = currentDate.getTime();
        if (this.#startDate && currentTime < this.#startDate.getTime()) {
            throw new Error(exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE);
        }
        if (this.#endDate && currentTime > this.#endDate.getTime()) {
            throw new Error(exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE);
        }
    }
    /**
     * Finds the next or previous schedule based on the cron expression.
     *
     * @param {boolean} [reverse=false] - If true, finds the previous schedule; otherwise, finds the next schedule.
     * @returns {CronDate} - The next or previous schedule date.
     * @private
     */
    #findSchedule(reverse = false) {
        const dateMathVerb = reverse ? CronDate_1.DateMathOp.Subtract : CronDate_1.DateMathOp.Add;
        const currentDate = new CronDate_1.CronDate(this.#currentDate);
        const startTimestamp = currentDate.getTime();
        let stepCount = 0;
        while (++stepCount < LOOP_LIMIT) {
            this.#validateTimeSpan(currentDate);
            if (!this.#matchDayOfMonth(currentDate)) {
                currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Day, this.#fields.hour.values.length);
                continue;
            }
            if (!(this.#fields.dayOfWeek.nthDay <= 0 || Math.ceil(currentDate.getDate() / 7) === this.#fields.dayOfWeek.nthDay)) {
                currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Day, this.#fields.hour.values.length);
                continue;
            }
            if (!CronExpression.#matchSchedule(currentDate.getMonth() + 1, this.#fields.month.values)) {
                currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Month, this.#fields.hour.values.length);
                continue;
            }
            if (!this.#matchHour(currentDate, dateMathVerb, reverse)) {
                continue;
            }
            if (!CronExpression.#matchSchedule(currentDate.getMinutes(), this.#fields.minute.values)) {
                currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Minute, this.#fields.hour.values.length);
                continue;
            }
            if (!CronExpression.#matchSchedule(currentDate.getSeconds(), this.#fields.second.values)) {
                currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Second, this.#fields.hour.values.length);
                continue;
            }
            if (startTimestamp === currentDate.getTime()) {
                if (dateMathVerb === 'Add' || currentDate.getMilliseconds() === 0) {
                    currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Second, this.#fields.hour.values.length);
                }
                continue;
            }
            break;
        }
        /* istanbul ignore next - should be impossible under normal use to trigger the branch */
        if (stepCount > LOOP_LIMIT) {
            throw new Error(exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE);
        }
        if (currentDate.getMilliseconds() !== 0) {
            currentDate.setMilliseconds(0);
        }
        this.#currentDate = currentDate;
        return currentDate;
    }
    /**
     * Returns an iterator for iterating through future CronDate instances
     *
     * @name Symbol.iterator
     * @memberof CronExpression
     * @returns {Iterator<CronDate>} An iterator object for CronExpression that returns CronDate values.
     */
    [Symbol.iterator]() {
        return {
            next: () => {
                const schedule = this.#findSchedule();
                return { value: schedule, done: !this.hasNext() };
            },
        };
    }
}
exports.CronExpression = CronExpression;
exports.default = CronExpression;
