export class SMDate {
    date: Date | null = null;
    dayString: string[] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

    fullDayString: string[] = [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
    ];

    monthString: string[] = [
        "Jan",
        "Feb",
        "Mar",
        "Apr",
        "May",
        "Jun",
        "Jul",
        "Aug",
        "Sep",
        "Oct",
        "Nov",
        "Dec",
    ];

    fullMonthString: string[] = [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
    ];

    constructor(
        dateOrString: string | Date = "",
        options: { format?: string; utc?: boolean } = {}
    ) {
        this.date = null;

        if (typeof dateOrString === "string") {
            if (dateOrString.length > 0) {
                this.parse(dateOrString, options);
            }
        } else if (
            dateOrString instanceof Date &&
            !Number.isNaN(dateOrString.getTime())
        ) {
            this.date = dateOrString;
        }
    }

    /**
     * Parse a string date into a Date object
     *
     * @param {string} dateString The date string.
     * @param {object} options (optional) Options object.
     * @param {string} options.format (optional) The format of the date string.
     * @param {boolean} options.utc (optional) The date string is UTC.
     * @returns {SMDate} SMDate object.
     */
    public parse(
        dateString: string,
        options: { format?: string; utc?: boolean } = {}
    ): SMDate {
        const now = new Date();

        if (dateString.toLowerCase() == "now") {
            this.date = now;
            return this;
        }

        // Parse the date format to determine the order of the date components
        const order = (options.format || "dmy").toLowerCase().split("");
        options.utc = options.utc || false;

        let components = [];
        let time = "";

        // Test if the dateString is in ISO 8601
        if (
            /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,10})?Z$/i.test(
                dateString
            )
        ) {
            options.format = "YMd";
            [dateString, time] = dateString.split("T");
            time = time.slice(0, -8);
        }

        // Split the date string into an array of components based on the length of each date component
        components = dateString.split(/[ /-]/);

        for (let i = 0; i < components.length; i++) {
            if (components[i].includes(":")) {
                time = components[i];
                components.splice(i, 1);
                if (
                    i < components.length &&
                    /^(am?|a\.m\.|pm?|p\.m\.)$/i.test(components[i])
                ) {
                    time += " " + components[i].toUpperCase();
                    components.splice(i, 1);
                }
                break;
            }
        }

        if (components.every((v) => !isNaN(parseInt(v))) == false) {
            return this;
        }

        if (components.length > 3) {
            return this;
        }

        // Map the date components to the expected order based on the format
        const [day, month, year] =
            order[0] === "d"
                ? [components[0], components[1], components[2]]
                : order[0] === "m"
                ? [components[1], components[0], components[2]]
                : [components[2], components[1], components[0]];

        let parsedDay: number = 0,
            parsedMonth: number = 0,
            parsedYear: number = 0;

        if (year.length == 3 || year.length >= 5) {
            return this;
        }

        if (day && day.length != 0 && month && month.length != 0) {
            // Parse the day, month, and year components
            parsedDay = parseInt(day.padStart(2, "0"), 10);
            parsedMonth = this.getMonthAsNumber(month);
            parsedYear = year
                ? parseInt(year.padStart(4, "20"), 10)
                : now.getFullYear();
        } else {
            parsedDay = now.getDate();
            parsedMonth = now.getMonth() + 1;
            parsedYear = now.getFullYear();
        }

        let parsedHours: number = 0,
            parsedMinutes: number = 0,
            parsedSeconds: number = 0;
        if (time) {
            const regEx = new RegExp(
                /^(\d+)(?::(\d+))?(?::(\d+))? ?(am?|a\.m\.|pm?|p\.m\.)?$/,
                "i"
            );
            if (regEx.test(time)) {
                const match = time.match(regEx);
                if (match) {
                    parsedHours = parseInt(match[1]);
                    parsedMinutes = match[2] ? parseInt(match[2]) : 0;
                    parsedSeconds = match[3] ? parseInt(match[3]) : 0;
                    if (match[4] && /pm/i.test(match[4])) {
                        parsedHours += 12;
                    }
                    if (
                        match[4] &&
                        /am/i.test(match[4]) &&
                        parsedHours === 12
                    ) {
                        parsedHours = 0;
                    }
                } else {
                    return this;
                }
            } else {
                return this;
            }
        }

        // Create a date object with the parsed components
        let date: Date | null = null;
        if (options.utc) {
            date = new Date(
                Date.UTC(
                    parsedYear,
                    parsedMonth - 1,
                    parsedDay,
                    parsedHours,
                    parsedMinutes,
                    parsedSeconds
                )
            );
        } else {
            date = new Date(
                parsedYear,
                parsedMonth - 1,
                parsedDay,
                parsedHours,
                parsedMinutes,
                parsedSeconds
            );
        }

        // Test created date object
        let checkYear: number,
            checkMonth: number,
            checkDay: number,
            checkHours: number,
            checkMinutes: number,
            checkSeconds: number;
        if (options.utc) {
            const isoDate = date.toISOString();
            checkYear = parseInt(isoDate.substring(0, 4), 10);
            checkMonth = parseInt(isoDate.substring(5, 7), 10);
            checkDay = new Date(isoDate).getUTCDate();
            checkHours = parseInt(isoDate.substring(11, 13), 10);
            checkMinutes = parseInt(isoDate.substring(14, 16), 10);
            checkSeconds = parseInt(isoDate.substring(17, 19), 10);
        } else {
            checkYear = date.getFullYear();
            checkMonth = date.getMonth() + 1;
            checkDay = date.getDate();
            checkHours = date.getHours();
            checkMinutes = date.getMinutes();
            checkSeconds = date.getSeconds();
        }

        if (
            Number.isNaN(date.getTime()) == false &&
            checkYear == parsedYear &&
            checkMonth == parsedMonth &&
            checkDay == parsedDay &&
            checkHours == parsedHours &&
            checkMinutes == parsedMinutes &&
            checkSeconds == parsedSeconds
        ) {
            this.date = date;
        } else {
            this.date = null;
        }

        return this;
    }

    /**
     * Format the date to a string.
     *
     * @param {string} format The format to return.
     * @param {object} options (optional) Function options.
     * @param {boolean} options.utc (optional) Format the date to be as UTC instead of local.
     * @returns {string} The formatted date.
     */
    public format(format: string, options: { utc?: boolean } = {}): string {
        if (this.date == null) {
            return "";
        }

        let result = format;

        let year: string,
            month: string,
            date: string,
            day: number,
            hour: string,
            min: string,
            sec: string;
        if (options.utc) {
            const isoDate = this.date.toISOString();
            year = isoDate.substring(0, 4);
            month = isoDate.substring(5, 7);
            date = isoDate.substring(8, 10);
            day = new Date(isoDate).getUTCDay();
            hour = isoDate.substring(11, 13);
            min = isoDate.substring(14, 16);
            sec = isoDate.substring(17, 18);
        } else {
            year = this.date.getFullYear().toString();
            month = (this.date.getMonth() + 1).toString();
            date = this.date.getDate().toString();
            day = this.date.getDay();
            hour = this.date.getHours().toString();
            min = this.date.getMinutes().toString();
            sec = this.date.getSeconds().toString();
        }

        const apm = parseInt(hour, 10) >= 12 ? "pm" : "am";
        /* eslint-disable indent */
        const apmhours = (
            parseInt(hour, 10) > 12
                ? parseInt(hour, 10) - 12
                : parseInt(hour, 10) == 0
                ? 12
                : parseInt(hour, 10)
        ).toString();
        /* eslint-enable indent */

        // year
        result = result.replace(/\byy\b/g, year.slice(-2));
        result = result.replace(/\byyyy\b/g, year);

        // month
        result = result.replace(/\bM\b/g, month);
        result = result.replace(/\bMM\b/g, (0 + month).slice(-2));
        result = result.replace(
            /\bMMM\b/g,
            this.monthString[parseInt(month) - 1]
        );
        result = result.replace(
            /\bMMMM\b/g,
            this.fullMonthString[parseInt(month) - 1]
        );

        // day
        result = result.replace(/\bd\b/g, date);
        result = result.replace(/\bdd\b/g, (0 + date).slice(-2));
        result = result.replace(/\bEEE\b/g, this.dayString[day]);
        result = result.replace(/\bEEEE\b/g, this.fullDayString[day]);

        // hour
        result = result.replace(/\bH\b/g, hour);
        result = result.replace(/\bHH\b/g, (0 + hour).slice(-2));
        result = result.replace(/\bh\b/g, apmhours);
        result = result.replace(/\bhh\b/g, (0 + apmhours).slice(-2));

        // min
        result = result.replace(/\bm\b/g, min);
        result = result.replace(/\bmm\b/g, (0 + min).slice(-2));

        // sec
        result = result.replace(/\bs\b/g, sec);
        result = result.replace(/\bss\b/g, (0 + sec).slice(-2));

        // am/pm
        result = result.replace(/\baa\b/g, apm);

        return result;
    }

    /**
     * Return a relative date string from now.
     *
     * @returns {string} A relative date string.
     */
    public relative(): string {
        let prefix = "";
        let postfix = " ago";

        if (this.date === null) {
            return "";
        }

        const now = new Date();
        let dif = Math.round((now.getTime() - this.date.getTime()) / 1000);

        if (dif < 0) {
            dif = Math.abs(dif);
            prefix = "In ";
            postfix = "";
        }

        if (dif < 60) {
            return "Just now";
        } else if (dif < 3600) {
            const v = Math.round(dif / 60);
            return prefix + v + " min" + (v != 1 ? "s" : "") + postfix;
        } else if (dif < 86400) {
            const v = Math.round(dif / 3600);
            return prefix + v + " hour" + (v != 1 ? "s" : "") + postfix;
        } else if (dif < 604800) {
            const v = Math.round(dif / 86400);
            return prefix + v + " day" + (v != 1 ? "s" : "") + postfix;
        } else if (dif < 2419200) {
            const v = Math.round(dif / 604800);
            return prefix + v + " week" + (v != 1 ? "s" : "") + postfix;
        }

        return (
            this.monthString[this.date.getMonth()] +
            " " +
            this.date.getDate() +
            ", " +
            this.date.getFullYear()
        );
    }

    /**
     * If the date is before the passed date.
     *
     * @param {Date|SMDate} d (optional) The date to check. If none, use now
     * @returns {boolean} If the date is before the passed date.
     */
    public isBefore(d: Date | SMDate = new SMDate("now")): boolean {
        const otherDate = d instanceof SMDate ? d.date : d;
        if (otherDate == null) {
            return false;
        }

        if (this.date == null) {
            return true;
        }

        return otherDate > this.date;
    }

    /**
     * If the date is after the passed date.
     *
     * @param {Date|SMDate} d (optional) The date to check. If none, use now
     * @returns {boolean} If the date is after the passed date.
     */
    public isAfter(d: Date | SMDate = new SMDate("now")): boolean {
        const otherDate = d instanceof SMDate ? d.date : d;
        if (otherDate == null) {
            return false;
        }

        if (this.date == null) {
            return true;
        }

        return otherDate < this.date;
    }

    /**
     * Return a month number from a string or a month number or month name
     *
     * @param {string} monthString The month string as number or name
     * @returns {number} The month number
     */
    private getMonthAsNumber(monthString: string): number {
        const months = this.fullMonthString.map((month) => month.toLowerCase());

        const shortMonths = months.map((month) => month.slice(0, 3));
        const monthIndex = months.indexOf(monthString.toLowerCase());
        if (monthIndex !== -1) {
            return monthIndex + 1;
        }
        const shortMonthIndex = shortMonths.indexOf(monthString.toLowerCase());
        if (shortMonthIndex !== -1) {
            return shortMonthIndex + 1;
        }
        const monthNumber = parseInt(monthString, 10);
        if (!isNaN(monthNumber) && monthNumber >= 1 && monthNumber <= 12) {
            return monthNumber;
        }

        return 0;
    }

    /**
     * Test if the current date is valid.
     *
     * @returns {boolean} If the current date is valid.
     */
    public isValid(): boolean {
        return this.date !== null;
    }

    /**
     * Return a string with only the first occurrence of characters
     *
     * @param {string} str The string to modify.
     * @param {string} characters The characters to use to test.
     * @returns {string} A string that only contains the first occurrence of the characters.
     */
    private onlyFirstOccurrence(
        str: string,
        characters: string = "dMy"
    ): string {
        let findCharacters = characters.split("");
        const replaceRegex = new RegExp("[^" + characters + "]", "g");
        let result = "";

        str = str.replace(replaceRegex, "");
        if (str.length > 0) {
            str.split("").forEach((strChar) => {
                if (
                    findCharacters.length > 0 &&
                    findCharacters.includes(strChar)
                ) {
                    result += strChar;

                    const index = findCharacters.findIndex(
                        (findChar) => findChar === strChar
                    );
                    if (index !== -1) {
                        findCharacters = findCharacters
                            .slice(0, index)
                            .concat(findCharacters.slice(index + 1));
                    }
                }
            });
        }

        return result;
    }
}
