Source: types/local-date.js

"use strict";
const utils = require("../utils");
/** @module types */

const millisecondsPerDay = 86400000;
/**
 * 2^31 days before unix epoch is -5877641-06-23. This is the first day that can be represented by this class.
 */
const dateCenter = Math.pow(2, 31);

const daysInAMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

/**
 * @param {Number} year
 * @returns {boolean}
 */
function isLeapYear(year) {
    return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}

/**
 * A date without a time-zone in the ISO-8601 calendar system, such as 2010-08-05.
 *
 * LocalDate is an immutable object that represents a date, often viewed as year-month-day. For example, the value "1st October 2014" can be stored in a LocalDate.
 *
 * This class does not store or represent a time or time-zone. Instead, it is a description of the date, as used for birthdays.
 * It cannot represent an instant on the time-line without additional information such as an offset or time-zone.
 *
 * Note that this type can represent dates in the range [-5877641-06-23; 5881580-07-17]
 * while the ES5 date type can only represent values in the range of [-271821-04-20; 275760-09-13].
 * In the event that year, month, day parameters do not fall within the ES5 date range an Error will be thrown.
 * If you wish to represent a date outside of this range, pass a single
 * parameter indicating the days since epoch. For example, -1 represents 1969-12-31.
 * @property {Date} date The date representation if falls within a range of an ES5 data type, otherwise an invalid date.
 */
class LocalDate {
    /**
     * @type {Number}
     */
    #year;
    /**
     * @type {Number}
     */
    #month;
    /**
     * @type {Number}
     */
    #day;
    /**
     * @type {Number}
     */
    #value;
    /**
     * @type {Date}
     */
    #date;

    /**
     * Creates a new instance of LocalDate.
     * @param {Number} yearOrDaysSinceEpoch The year or days since epoch. If days since epoch, month and day should not be provided.
     * @param {Number} month Between 1 and 12 inclusive.
     * @param {Number} day Between 1 and the number of days in the given month of the given year.
     */
    constructor(yearOrDaysSinceEpoch, month, day) {
        // implementation detail: internally uses a UTC based date
        if (
            typeof yearOrDaysSinceEpoch === "number" &&
            typeof month === "number" &&
            typeof day === "number"
        ) {
            // First case: year represents a year

            // Use setUTCFullYear as if there is a 2 digit year, Date.UTC() assumes
            // that is the 20th century.
            this.#date = new Date();
            this.#date.setUTCHours(0, 0, 0, 0);
            // We need those checks, as setUTCFullYear does not fail if provided with invalid data.
            if (month < 1 || month > 12 || Math.round(month) != month) {
                throw new Error(
                    `Month should be value between 1 and 12, got ${month}.`,
                );
            }
            if (day < 1 || day > daysInAMonth[month - 1]) {
                throw new Error(
                    `Day should be value between 1 and ${daysInAMonth[month - 1]}, got ${day}.`,
                );
            }
            if (month == 2 && day == 29 && !isLeapYear(yearOrDaysSinceEpoch)) {
                throw new Error(
                    `Day should be value between 1 and 28 (non leap year), got ${day}.`,
                );
            }
            this.#date.setUTCFullYear(yearOrDaysSinceEpoch, month - 1, day);
            if (isNaN(this.date.getTime())) {
                throw new Error(
                    `${yearOrDaysSinceEpoch}-${month}-${day} does not form a valid ES5 date!`,
                );
            }
        } else if (typeof month === "undefined" && typeof day === "undefined") {
            // The second case: first argument represents days since epoch
            if (typeof yearOrDaysSinceEpoch === "number") {
                // in days since epoch.
                if (
                    yearOrDaysSinceEpoch < -2147483648 ||
                    yearOrDaysSinceEpoch > 2147483647
                ) {
                    throw new Error(
                        "You must provide a valid value for days since epoch (-2147483648 <= value <= 2147483647).",
                    );
                }
                this.#date = new Date(
                    yearOrDaysSinceEpoch * millisecondsPerDay,
                );
            }
        }

        if (typeof this.#date === "undefined") {
            throw new Error("You must provide a valid year, month and day");
        }

        this.#value = isNaN(this.date.getTime()) ? yearOrDaysSinceEpoch : null;
        this.#year = this.date.getUTCFullYear();
        this.#month = this.date.getUTCMonth() + 1;
        this.#day = this.date.getUTCDate();
    }

    /**
     * A number representing the year. May return NaN if cannot be represented as a Date.
     * @readonly
     * @type {Number}
     */
    get year() {
        return this.#year;
    }

    set year(_) {
        throw new SyntaxError("LocalDate year is read-only");
    }

    /**
     * A number between 1 and 12 inclusive representing the month.
     * May return NaN if cannot be represented as a Date.
     * @readonly
     * @type {Number}
     */
    get month() {
        return this.#month;
    }

    set month(_) {
        throw new SyntaxError("LocalDate month is read-only");
    }

    /**
     * A number between 1 and the number of days in the given month of the given year (value up to 31).
     * May return NaN if cannot be represented as a Date.
     * @readonly
     * @type {Number}
     */
    get day() {
        return this.#day;
    }

    set day(_) {
        throw new SyntaxError("LocalDate day is read-only");
    }

    /**
     * Date object represent this date.
     * @readonly
     * @type {Date}
     */
    get date() {
        return this.#date;
    }

    set date(_) {
        throw new SyntaxError("LocalDate date is read-only");
    }

    /**
     * If date cannot be represented yet given a valid days since epoch, track it internally.
     * @readonly
     * @deprecated This member is in Datastax documentation, but it seems to not be exposed in the API.
     * Additionally we added a new class member: `value` that always returns days since epoch regardless of the date.
     * @type {Number}
     */
    get _value() {
        return this.#value;
    }

    set _value(_) {
        throw new SyntaxError("LocalDate _value is read-only");
    }

    /**
     * Always valid amount of days since epoch.
     * @readonly
     * @type {Number}
     */
    get value() {
        return this.#value
            ? this.#value
            : Math.floor(this.date.valueOf() / millisecondsPerDay);
    }

    set value(_) {
        throw new SyntaxError("LocalDate value is read-only");
    }

    /**
     * Creates a new instance of LocalDate using the current year, month and day from the system clock in the default time-zone.
     */
    static now() {
        return LocalDate.fromDate(new Date());
    }

    /**
     * Creates a new instance of LocalDate using the current date from the system clock at UTC.
     */
    static utcNow() {
        return new LocalDate(Date.now());
    }

    /**
     * Creates a new instance of LocalDate using the year, month and day from the provided local date time.
     * @param {Date} date
     */
    static fromDate(date) {
        if (isNaN(date.getTime())) {
            throw new TypeError(`Invalid date: ${date}`);
        }
        return new LocalDate(
            date.getFullYear(),
            date.getMonth() + 1,
            date.getDate(),
        );
    }

    /**
     * Creates a new instance of LocalDate using the year, month and day provided in the form: yyyy-mm-dd or
     * days since epoch (i.e. -1 for Dec 31, 1969).
     * @param {String} value
     */
    static fromString(value) {
        const dashCount = (value.match(/-/g) || []).length;
        if (dashCount >= 2) {
            let multiplier = 1;
            if (value[0] === "-") {
                value = value.substring(1);
                multiplier = -1;
            }
            const parts = value.split("-");
            return new LocalDate(
                multiplier * parseInt(parts[0], 10),
                parseInt(parts[1], 10),
                parseInt(parts[2], 10),
            );
        }
        if (value.match(/^-?\d+$/)) {
            // Parse as days since epoch.
            return new LocalDate(parseInt(value, 10));
        }
        throw new Error(`Invalid input '${value}'.`);
    }

    /**
     * Creates a new instance of LocalDate using the bytes representation.
     * @param {Buffer} buffer
     */
    static fromBuffer(buffer) {
        // move to unix epoch: 0.
        return new LocalDate(buffer.readUInt32BE(0) - dateCenter);
    }

    /**
     * Compares this LocalDate with the given one.
     * @param {LocalDate} other date to compare against.
     * @return {number} 0 if they are the same, 1 if the this is greater, and -1
     * if the given one is greater.
     */
    compare(other) {
        const thisValue = isNaN(this.date.getTime())
            ? this._value * millisecondsPerDay
            : this.date.getTime();
        const otherValue = isNaN(other.date.getTime())
            ? other._value * millisecondsPerDay
            : other.date.getTime();
        const diff = thisValue - otherValue;
        if (diff < 0) {
            return -1;
        }
        if (diff > 0) {
            return 1;
        }
        return 0;
    }

    /**
     * Returns true if the value of the LocalDate instance and other are the same
     * @param {LocalDate} other
     * @returns {Boolean}
     */
    equals(other) {
        return other instanceof LocalDate && this.compare(other) === 0;
    }

    inspect() {
        return `${this.constructor.name} : ${this.toString()}`;
    }

    /**
     * Gets the bytes representation of the instance.
     * @returns {Buffer}
     */
    toBuffer() {
        // days since unix epoch
        const daysSinceEpoch = isNaN(this.date.getTime())
            ? this._value
            : Math.floor(this.date.getTime() / millisecondsPerDay);
        const value = daysSinceEpoch + dateCenter;
        const buf = utils.allocBufferUnsafe(4);
        buf.writeUInt32BE(value, 0);
        return buf;
    }

    /**
     * Gets the string representation of the instance in the form: yyyy-mm-dd if
     * the value can be parsed as a Date, otherwise days since epoch.
     * @returns {String}
     */
    toString() {
        let result;
        // if cannot be parsed as date, return days since epoch representation.
        if (isNaN(this.date.getTime())) {
            return this._value.toString();
        }
        if (this.year < 0) {
            result = "-" + fillZeros((this.year * -1).toString(), 4);
        } else {
            result = fillZeros(this.year.toString(), 4);
        }
        result +=
            "-" +
            fillZeros(this.month.toString(), 2) +
            "-" +
            fillZeros(this.day.toString(), 2);
        return result;
    }

    /**
     * Gets the string representation of the instance in the form: yyyy-mm-dd, valid for JSON.
     * @returns {String}
     */
    toJSON() {
        return this.toString();
    }
}

/**
 * @param {String} value
 * @param {Number} amount
 * @private
 */
function fillZeros(value, amount) {
    if (value.length >= amount) {
        return value;
    }
    return utils.stringRepeat("0", amount - value.length) + value;
}

module.exports = LocalDate;