const dayjs = require("dayjs");
const { UP, MAINTENANCE, DOWN, PENDING } = require("../src/util");
const { LimitQueue } = require("./utils/limit-queue");
const { log } = require("../src/util");
const { R } = require("redbean-node");

/**
 * Calculates the uptime of a monitor.
 */
class UptimeCalculator {
    /**
     * @private
     * @type {{string:UptimeCalculator}}
     */

    static list = {};

    /**
     * For testing purposes, we can set the current date to a specific date.
     * @type {dayjs.Dayjs}
     */
    static currentDate = null;

    /**
     * monitorID the id of the monitor
     * @type {number}
     */
    monitorID;

    /**
     * Recent 24-hour uptime, each item is a 1-minute interval
     * Key: {number} DivisionKey
     * @type {LimitQueue<number,string>}
     */
    minutelyUptimeDataList = new LimitQueue(24 * 60);

    /**
     * Recent 30-day uptime, each item is a 1-hour interval
     * Key: {number} DivisionKey
     * @type {LimitQueue<number,string>}
     */
    hourlyUptimeDataList = new LimitQueue(30 * 24);

    /**
     * Daily uptime data,
     * Key: {number} DailyKey
     */
    dailyUptimeDataList = new LimitQueue(365);

    lastUptimeData = null;
    lastHourlyUptimeData = null;
    lastDailyUptimeData = null;

    lastDailyStatBean = null;
    lastHourlyStatBean = null;
    lastMinutelyStatBean = null;

    /**
     * Get the uptime calculator for a monitor
     * Initializes and returns the monitor if it does not exist
     * @param {number} monitorID the id of the monitor
     * @returns {Promise<UptimeCalculator>} UptimeCalculator
     */
    static async getUptimeCalculator(monitorID) {
        if (!monitorID) {
            throw new Error("Monitor ID is required");
        }

        if (!UptimeCalculator.list[monitorID]) {
            UptimeCalculator.list[monitorID] = new UptimeCalculator();
            await UptimeCalculator.list[monitorID].init(monitorID);
        }
        return UptimeCalculator.list[monitorID];
    }

    /**
     * Remove a monitor from the list
     * @param {number} monitorID the id of the monitor
     * @returns {Promise<void>}
     */
    static async remove(monitorID) {
        delete UptimeCalculator.list[monitorID];
    }

    /**
     *
     */
    constructor() {
        if (process.env.TEST_BACKEND) {
            // Override the getCurrentDate() method to return a specific date
            // Only for testing
            this.getCurrentDate = () => {
                if (UptimeCalculator.currentDate) {
                    return UptimeCalculator.currentDate;
                } else {
                    return dayjs.utc();
                }
            };
        }
    }

    /**
     * Initialize the uptime calculator for a monitor
     * @param {number} monitorID the id of the monitor
     * @returns {Promise<void>}
     */
    async init(monitorID) {
        this.monitorID = monitorID;

        let now = this.getCurrentDate();

        // Load minutely data from database (recent 24 hours only)
        let minutelyStatBeans = await R.find("stat_minutely", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
            monitorID,
            this.getMinutelyKey(now.subtract(24, "hour")),
        ]);

        for (let bean of minutelyStatBeans) {
            let data = {
                up: bean.up,
                down: bean.down,
                avgPing: bean.ping,
                minPing: bean.pingMin,
                maxPing: bean.pingMax,
            };

            if (bean.extras != null) {
                data = {
                    ...data,
                    ...JSON.parse(bean.extras),
                };
            }

            let key = bean.timestamp;
            this.minutelyUptimeDataList.push(key, data);
        }

        // Load hourly data from database (recent 30 days only)
        let hourlyStatBeans = await R.find("stat_hourly", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
            monitorID,
            this.getHourlyKey(now.subtract(30, "day")),
        ]);

        for (let bean of hourlyStatBeans) {
            let data = {
                up: bean.up,
                down: bean.down,
                avgPing: bean.ping,
                minPing: bean.pingMin,
                maxPing: bean.pingMax,
            };

            if (bean.extras != null) {
                data = {
                    ...data,
                    ...JSON.parse(bean.extras),
                };
            }

            this.hourlyUptimeDataList.push(bean.timestamp, data);
        }

        // Load daily data from database (recent 365 days only)
        let dailyStatBeans = await R.find("stat_daily", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
            monitorID,
            this.getDailyKey(now.subtract(365, "day")),
        ]);

        for (let bean of dailyStatBeans) {
            let data = {
                up: bean.up,
                down: bean.down,
                avgPing: bean.ping,
                minPing: bean.pingMin,
                maxPing: bean.pingMax,
            };

            if (bean.extras != null) {
                data = {
                    ...data,
                    ...JSON.parse(bean.extras),
                };
            }

            this.dailyUptimeDataList.push(bean.timestamp, data);
        }
    }

    /**
     * @param {number} status status
     * @param {number} ping Ping
     * @param {dayjs.Dayjs} date Date (Only for migration)
     * @returns {dayjs.Dayjs} date
     * @throws {Error} Invalid status
     */
    async update(status, ping = 0, date) {
        if (!date) {
            date = this.getCurrentDate();
        }

        let flatStatus = this.flatStatus(status);

        if (flatStatus === DOWN && ping > 0) {
            log.warn("uptime-calc", "The ping is not effective when the status is DOWN");
        }

        let divisionKey = this.getMinutelyKey(date);
        let hourlyKey = this.getHourlyKey(date);
        let dailyKey = this.getDailyKey(date);

        let minutelyData = this.minutelyUptimeDataList[divisionKey];
        let hourlyData = this.hourlyUptimeDataList[hourlyKey];
        let dailyData = this.dailyUptimeDataList[dailyKey];

        if (status === MAINTENANCE) {
            minutelyData.maintenance = minutelyData.maintenance ? minutelyData.maintenance + 1 : 1;
            hourlyData.maintenance = hourlyData.maintenance ? hourlyData.maintenance + 1 : 1;
            dailyData.maintenance = dailyData.maintenance ? dailyData.maintenance + 1 : 1;

        } else if (flatStatus === UP) {
            minutelyData.up += 1;
            hourlyData.up += 1;
            dailyData.up += 1;

            // Only UP status can update the ping
            if (!isNaN(ping)) {
                // Add avg ping
                // The first beat of the minute, the ping is the current ping
                if (minutelyData.up === 1) {
                    minutelyData.avgPing = ping;
                    minutelyData.minPing = ping;
                    minutelyData.maxPing = ping;
                } else {
                    minutelyData.avgPing = (minutelyData.avgPing * (minutelyData.up - 1) + ping) / minutelyData.up;
                    minutelyData.minPing = Math.min(minutelyData.minPing, ping);
                    minutelyData.maxPing = Math.max(minutelyData.maxPing, ping);
                }

                // Add avg ping
                // The first beat of the hour, the ping is the current ping
                if (hourlyData.up === 1) {
                    hourlyData.avgPing = ping;
                    hourlyData.minPing = ping;
                    hourlyData.maxPing = ping;
                } else {
                    hourlyData.avgPing = (hourlyData.avgPing * (hourlyData.up - 1) + ping) / hourlyData.up;
                    hourlyData.minPing = Math.min(hourlyData.minPing, ping);
                    hourlyData.maxPing = Math.max(hourlyData.maxPing, ping);
                }

                // Add avg ping (daily)
                // The first beat of the day, the ping is the current ping
                if (dailyData.up === 1) {
                    dailyData.avgPing = ping;
                    dailyData.minPing = ping;
                    dailyData.maxPing = ping;
                } else {
                    dailyData.avgPing = (dailyData.avgPing * (dailyData.up - 1) + ping) / dailyData.up;
                    dailyData.minPing = Math.min(dailyData.minPing, ping);
                    dailyData.maxPing = Math.max(dailyData.maxPing, ping);
                }
            }

        } else if (flatStatus === DOWN) {
            minutelyData.down += 1;
            hourlyData.down += 1;
            dailyData.down += 1;
        }

        if (minutelyData !== this.lastUptimeData) {
            this.lastUptimeData = minutelyData;
        }

        if (hourlyData !== this.lastHourlyUptimeData) {
            this.lastHourlyUptimeData = hourlyData;
        }

        if (dailyData !== this.lastDailyUptimeData) {
            this.lastDailyUptimeData = dailyData;
        }

        // Don't store data in test mode
        if (process.env.TEST_BACKEND) {
            log.debug("uptime-calc", "Skip storing data in test mode");
            return date;
        }

        let dailyStatBean = await this.getDailyStatBean(dailyKey);
        dailyStatBean.up = dailyData.up;
        dailyStatBean.down = dailyData.down;
        dailyStatBean.ping = dailyData.avgPing;
        dailyStatBean.pingMin = dailyData.minPing;
        dailyStatBean.pingMax = dailyData.maxPing;
        {
            // eslint-disable-next-line no-unused-vars
            const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = dailyData;
            if (Object.keys(extras).length > 0) {
                dailyStatBean.extras = JSON.stringify(extras);
            }
        }
        await R.store(dailyStatBean);

        let hourlyStatBean = await this.getHourlyStatBean(hourlyKey);
        hourlyStatBean.up = hourlyData.up;
        hourlyStatBean.down = hourlyData.down;
        hourlyStatBean.ping = hourlyData.avgPing;
        hourlyStatBean.pingMin = hourlyData.minPing;
        hourlyStatBean.pingMax = hourlyData.maxPing;
        {
            // eslint-disable-next-line no-unused-vars
            const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
            if (Object.keys(extras).length > 0) {
                hourlyStatBean.extras = JSON.stringify(extras);
            }
        }
        await R.store(hourlyStatBean);

        let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
        minutelyStatBean.up = minutelyData.up;
        minutelyStatBean.down = minutelyData.down;
        minutelyStatBean.ping = minutelyData.avgPing;
        minutelyStatBean.pingMin = minutelyData.minPing;
        minutelyStatBean.pingMax = minutelyData.maxPing;
        {
            // eslint-disable-next-line no-unused-vars
            const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
            if (Object.keys(extras).length > 0) {
                minutelyStatBean.extras = JSON.stringify(extras);
            }
        }
        await R.store(minutelyStatBean);

        // Remove the old data
        log.debug("uptime-calc", "Remove old data");
        await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
            this.monitorID,
            this.getMinutelyKey(date.subtract(24, "hour")),
        ]);

        await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ? AND timestamp < ?", [
            this.monitorID,
            this.getHourlyKey(date.subtract(30, "day")),
        ]);

        return date;
    }

    /**
     * Get the daily stat bean
     * @param {number} timestamp milliseconds
     * @returns {Promise<import("redbean-node").Bean>} stat_daily bean
     */
    async getDailyStatBean(timestamp) {
        if (this.lastDailyStatBean && this.lastDailyStatBean.timestamp === timestamp) {
            return this.lastDailyStatBean;
        }

        let bean = await R.findOne("stat_daily", " monitor_id = ? AND timestamp = ?", [
            this.monitorID,
            timestamp,
        ]);

        if (!bean) {
            bean = R.dispense("stat_daily");
            bean.monitor_id = this.monitorID;
            bean.timestamp = timestamp;
        }

        this.lastDailyStatBean = bean;
        return this.lastDailyStatBean;
    }

    /**
     * Get the hourly stat bean
     * @param {number} timestamp milliseconds
     * @returns {Promise<import("redbean-node").Bean>} stat_hourly bean
     */
    async getHourlyStatBean(timestamp) {
        if (this.lastHourlyStatBean && this.lastHourlyStatBean.timestamp === timestamp) {
            return this.lastHourlyStatBean;
        }

        let bean = await R.findOne("stat_hourly", " monitor_id = ? AND timestamp = ?", [
            this.monitorID,
            timestamp,
        ]);

        if (!bean) {
            bean = R.dispense("stat_hourly");
            bean.monitor_id = this.monitorID;
            bean.timestamp = timestamp;
        }

        this.lastHourlyStatBean = bean;
        return this.lastHourlyStatBean;
    }

    /**
     * Get the minutely stat bean
     * @param {number} timestamp milliseconds
     * @returns {Promise<import("redbean-node").Bean>} stat_minutely bean
     */
    async getMinutelyStatBean(timestamp) {
        if (this.lastMinutelyStatBean && this.lastMinutelyStatBean.timestamp === timestamp) {
            return this.lastMinutelyStatBean;
        }

        let bean = await R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ?", [
            this.monitorID,
            timestamp,
        ]);

        if (!bean) {
            bean = R.dispense("stat_minutely");
            bean.monitor_id = this.monitorID;
            bean.timestamp = timestamp;
        }

        this.lastMinutelyStatBean = bean;
        return this.lastMinutelyStatBean;
    }

    /**
     * Convert timestamp to minutely key
     * @param {dayjs.Dayjs} date The heartbeat date
     * @returns {number} Timestamp
     */
    getMinutelyKey(date) {
        // Truncate value to minutes (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:34:00)
        date = date.startOf("minute");

        // Convert to timestamp in second
        let divisionKey = date.unix();

        if (! (divisionKey in this.minutelyUptimeDataList)) {
            this.minutelyUptimeDataList.push(divisionKey, {
                up: 0,
                down: 0,
                avgPing: 0,
                minPing: 0,
                maxPing: 0,
            });
        }

        return divisionKey;
    }

    /**
     * Convert timestamp to hourly key
     * @param {dayjs.Dayjs} date The heartbeat date
     * @returns {number} Timestamp
     */
    getHourlyKey(date) {
        // Truncate value to hours (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:00:00)
        date = date.startOf("hour");

        // Convert to timestamp in second
        let divisionKey = date.unix();

        if (! (divisionKey in this.hourlyUptimeDataList)) {
            this.hourlyUptimeDataList.push(divisionKey, {
                up: 0,
                down: 0,
                avgPing: 0,
                minPing: 0,
                maxPing: 0,
            });
        }

        return divisionKey;
    }

    /**
     * Convert timestamp to daily key
     * @param {dayjs.Dayjs} date The heartbeat date
     * @returns {number} Timestamp
     */
    getDailyKey(date) {
        // Truncate value to start of day (e.g. 2021-01-01 12:34:56 -> 2021-01-01 00:00:00)
        // Considering if the user keep changing could affect the calculation, so use UTC time to avoid this problem.
        date = date.utc().startOf("day");
        let dailyKey = date.unix();

        if (!this.dailyUptimeDataList[dailyKey]) {
            this.dailyUptimeDataList.push(dailyKey, {
                up: 0,
                down: 0,
                avgPing: 0,
                minPing: 0,
                maxPing: 0,
            });
        }

        return dailyKey;
    }

    /**
     * Convert timestamp to key
     * @param {dayjs.Dayjs} datetime Datetime
     * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
     * @returns {number} Timestamp
     * @throws {Error} If the type is invalid
     */
    getKey(datetime, type) {
        switch (type) {
            case "day":
                return this.getDailyKey(datetime);
            case "hour":
                return this.getHourlyKey(datetime);
            case "minute":
                return this.getMinutelyKey(datetime);
            default:
                throw new Error("Invalid type");
        }
    }

    /**
     * Flat status to UP or DOWN
     * @param {number} status the status which should be turned into a flat status
     * @returns {UP|DOWN|PENDING} The flat status
     * @throws {Error} Invalid status
     */
    flatStatus(status) {
        switch (status) {
            case UP:
            case MAINTENANCE:
                return UP;
            case DOWN:
            case PENDING:
                return DOWN;
        }
        throw new Error("Invalid status");
    }

    /**
     * @param {number} num the number of data points which are expected to be returned
     * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
     * @returns {UptimeDataResult} UptimeDataResult
     * @throws {Error} The maximum number of minutes greater than 1440
     */
    getData(num, type = "day") {

        if (type === "hour" && num > 24 * 30) {
            throw new Error("The maximum number of hours is 720");
        }
        if (type === "minute" && num > 24 * 60) {
            throw new Error("The maximum number of minutes is 1440");
        }
        if (type === "day" && num > 365) {
            throw new Error("The maximum number of days is 365");
        }
        // Get the current time period key based on the type
        let key = this.getKey(this.getCurrentDate(), type);

        let total = {
            up: 0,
            down: 0,
        };

        let totalPing = 0;
        let endTimestamp;

        // Get the eariest timestamp of the required period based on the type
        switch (type) {
            case "day":
                endTimestamp = key - 86400 * (num - 1);
                break;
            case "hour":
                endTimestamp = key - 3600 * (num - 1);
                break;
            case "minute":
                endTimestamp = key - 60 * (num - 1);
                break;
            default:
                throw new Error("Invalid type");
        }

        // Sum up all data in the specified time range
        while (key >= endTimestamp) {
            let data;

            switch (type) {
                case "day":
                    data = this.dailyUptimeDataList[key];
                    break;
                case "hour":
                    data = this.hourlyUptimeDataList[key];
                    break;
                case "minute":
                    data = this.minutelyUptimeDataList[key];
                    break;
                default:
                    throw new Error("Invalid type");
            }

            if (data) {
                total.up += data.up;
                total.down += data.down;
                totalPing += data.avgPing * data.up;
            }

            // Set key to the previous time period
            switch (type) {
                case "day":
                    key -= 86400;
                    break;
                case "hour":
                    key -= 3600;
                    break;
                case "minute":
                    key -= 60;
                    break;
                default:
                    throw new Error("Invalid type");
            }
        }

        let uptimeData = new UptimeDataResult();

        // If there is no data in the previous time ranges, use the last data?
        if (total.up === 0 && total.down === 0) {
            switch (type) {
                case "day":
                    if (this.lastDailyUptimeData) {
                        total = this.lastDailyUptimeData;
                        totalPing = total.avgPing * total.up;
                    } else {
                        return uptimeData;
                    }
                    break;
                case "hour":
                    if (this.lastHourlyUptimeData) {
                        total = this.lastHourlyUptimeData;
                        totalPing = total.avgPing * total.up;
                    } else {
                        return uptimeData;
                    }
                    break;
                case "minute":
                    if (this.lastUptimeData) {
                        total = this.lastUptimeData;
                        totalPing = total.avgPing * total.up;
                    } else {
                        return uptimeData;
                    }
                    break;
                default:
                    throw new Error("Invalid type");
            }
        }

        let avgPing;

        if (total.up === 0) {
            avgPing = null;
        } else {
            avgPing = totalPing / total.up;
        }

        if (total.up + total.down === 0) {
            uptimeData.uptime = 0;
        } else {
            uptimeData.uptime = total.up / (total.up + total.down);
        }
        uptimeData.avgPing = avgPing;
        return uptimeData;
    }

    /**
     * Get data in form of an array
     * @param {number} num the number of data points which are expected to be returned
     * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
     * @returns {Array<object>} uptime data
     * @throws {Error} The maximum number of minutes greater than 1440
     */
    getDataArray(num, type = "day") {
        if (type === "hour" && num > 24 * 30) {
            throw new Error("The maximum number of hours is 720");
        }
        if (type === "minute" && num > 24 * 60) {
            throw new Error("The maximum number of minutes is 1440");
        }

        // Get the current time period key based on the type
        let key = this.getKey(this.getCurrentDate(), type);

        let result = [];

        let endTimestamp;

        // Get the eariest timestamp of the required period based on the type
        switch (type) {
            case "day":
                endTimestamp = key - 86400 * (num - 1);
                break;
            case "hour":
                endTimestamp = key - 3600 * (num - 1);
                break;
            case "minute":
                endTimestamp = key - 60 * (num - 1);
                break;
            default:
                throw new Error("Invalid type");
        }

        // Get datapoints in the specified time range
        while (key >= endTimestamp) {
            let data;

            switch (type) {
                case "day":
                    data = this.dailyUptimeDataList[key];
                    break;
                case "hour":
                    data = this.hourlyUptimeDataList[key];
                    break;
                case "minute":
                    data = this.minutelyUptimeDataList[key];
                    break;
                default:
                    throw new Error("Invalid type");
            }

            if (data) {
                data.timestamp = key;
                result.push(data);
            }

            // Set key to the previous time period
            switch (type) {
                case "day":
                    key -= 86400;
                    break;
                case "hour":
                    key -= 3600;
                    break;
                case "minute":
                    key -= 60;
                    break;
                default:
                    throw new Error("Invalid type");
            }
        }

        return result;
    }

    /**
     * Get the uptime data for given duration.
     * @param {string} duration  A string with a number and a unit (m,h,d,w,M,y), such as 24h, 30d, 1y.
     * @returns {UptimeDataResult} UptimeDataResult
     * @throws {Error} Invalid duration / Unsupported unit
     */
    getDataByDuration(duration) {
        const durationNumStr = duration.slice(0, -1);

        if (!/^[0-9]+$/.test(durationNumStr)) {
            throw new Error(`Invalid duration: ${duration}`);
        }
        const num = Number(durationNumStr);
        const unit = duration.slice(-1);

        switch (unit) {
            case "m":
                return this.getData(num, "minute");
            case "h":
                return this.getData(num, "hour");
            case "d":
                return this.getData(num, "day");
            case "w":
                return this.getData(7 * num, "day");
            case "M":
                return this.getData(30 * num, "day");
            case "y":
                return this.getData(365 * num, "day");
            default:
                throw new Error(`Unsupported unit (${unit}) for badge duration ${duration}`
                );
        }
    }

    /**
     * 1440 = 24 * 60mins
     * @returns {UptimeDataResult} UptimeDataResult
     */
    get24Hour() {
        return this.getData(1440, "minute");
    }

    /**
     * @returns {UptimeDataResult} UptimeDataResult
     */
    get7Day() {
        return this.getData(168, "hour");
    }

    /**
     * @returns {UptimeDataResult} UptimeDataResult
     */
    get30Day() {
        return this.getData(30);
    }

    /**
     * @returns {UptimeDataResult} UptimeDataResult
     */
    get1Year() {
        return this.getData(365);
    }

    /**
     * @returns {dayjs.Dayjs} Current datetime in UTC
     */
    getCurrentDate() {
        return dayjs.utc();
    }

}

class UptimeDataResult {
    /**
     * @type {number} Uptime
     */
    uptime = 0;

    /**
     * @type {number} Average ping
     */
    avgPing = null;
}

module.exports = {
    UptimeCalculator,
    UptimeDataResult,
};
