/*
 * Queue - A Queueing system that can be used to handle labs in higher education
 * Copyright (C) 2016-2024  Delft University of Technology
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
/**
 * A calendar view into a set of time slots.
 */
class Calendar {
    /**
     * Constructs a calendar view.
     *
     * Options are:
     *  - onSelect: function that takes a time slot, called when a time slot is selected
     *  - onEdit: function that takes a time slot, called when a time slot is edited
     *
     * @param calendar The FullCalendar calendar to view
     * @param timeSlots The TimeSlots object to sync with
     * @param options Optional configuration.
     */
    constructor(calendar, timeSlots, options = {}) {
        this.calendar = calendar;
        timeSlots.calendars.push(this);
        this.calendar.setOption("events", function (info, successCallback, failureCallback) {
            successCallback(timeSlots.events);
        });

        if (options.onSelect) {
            this.calendar.setOption("eventClick", function (info) {
                const timeSlot = timeSlots.getSlot(info.event.id);
                if (timeSlot.preventSelecting) return;
                timeSlots.select(timeSlot);
                options.onSelect(timeSlot);
            });
        }
        if (options.onEdit) {
            function editEvent(info) {
                const timeSlot = timeSlots.getSlot(info.event.id);
                if (timeSlot.preventEditing) return;
                timeSlot.slot = { start: moment(info.event.start), end: moment(info.event.end) };
                options.onEdit(timeSlot);
            }
            this.calendar.setOption("eventResize", editEvent);
            this.calendar.setOption("eventDrop", editEvent);
        }
    }

    /**
     * Displays this calendar
     */
    show() {
        this.calendar.render();
    }

    /**
     * Updates this calendar to display new or edited events
     */
    update() {
        this.calendar.refetchEvents();
    }

    /**
     * Scrolls to the specified time.
     */
    scrollToTime(time) {
        this.calendar.scrollToTime(time);
    }
}

/**
 * A collection of time slots.
 */
class TimeSlotCollection {
    /**
     * Constructs an empty timeslot collection.
     */
    constructor() {
        this.timeSlots = [];
        this.selected = undefined;
        this.calendars = [];
        this.preventUpdates = false;
    }

    /**
     * Get a slot by ID
     * @param id {string} The id of the timeslot
     * @returns {TimeSlot} The timeslot with the corresponding ID
     */
    getSlot(id) {
        return this.timeSlots.find(ts => ts.id === id);
    }

    /**
     * Gets all slots between start and end.
     * @param start The start time
     * @param end The end time
     * @returns {TimeSlot[]}
     */
    getSlotsBetween(start, end) {
        return this.timeSlots.filter(ts => ts.start >= start && ts.end <= end);
    }

    /**
     * Returns whether this collection is empty.
     * @returns {boolean} True iff the collection is empty;
     */
    isEmpty() {
        return this.timeSlots.length === 0;
    }

    /**
     * Adds a timeslot.
     * @param {TimeSlot} slot The timeslot to add
     */
    addSlot(slot) {
        this.timeSlots.push(slot);
        slot.timeSlots = this;
        this.update();
    }

    /**
     * Removes a timeslot.
     * @param {TimeSlot} slot The timeslot to remove
     */
    removeSlot(slot) {
        this.timeSlots = this.timeSlots.filter(ts => ts !== slot);
        if (this.selected === slot) {
            this.selected = undefined;
        }
        slot.remove();
    }

    /**
     * Deselects the currently selected timeslot.
     */
    deselect() {
        if (this.selected) {
            this.selected.selected = false;
        }
        this.selected = undefined;
        this.update();
    }

    /**
     * Selects a timeslot.
     * @param {TimeSlot} slot The timeslot to select
     */
    select(slot) {
        if (this.selected) {
            this.selected.selected = false;
        }
        slot.selected = true;
        this.selected = slot;
        this.update();
    }

    /**
     * Stop updating until `bulkUpdate()` is called.
     */
    waitForBulkUpdate() {
        this.preventUpdates = true;
    }

    /**
     * Update and resume regular updating.
     */
    bulkUpdate() {
        this.preventUpdates = false;
        this.update();
    }

    /**
     * Updates all calendars associated with this set of timeslots.
     */
    update() {
        if (this.preventUpdates) return;
        this.calendars.forEach(calendar => calendar.update());
    }

    /**
     * Gets the timeslots in event format for FullCalendar.
     * @returns {*[]}
     */
    get events() {
        return this.timeSlots.map(ts => ts.toEvent());
    }

    /**
     * Gets the timeslots as a collection of HTML input elements for a form.
     * @returns {HTMLDivElement}
     */
    get inputs() {
        const div = document.createElement("div");
        for (let i = 0; i < this.timeSlots.length; i++) {
            div.appendChild(this.timeSlots[i].toInputs(i));
        }
        return div;
    }

    /**
     * Gets the time range, i.e. the minimum starting time and maximum ending time in a day.
     * @returns {{start: string, end: string}}
     */
    get timeRange() {
        let min = 23 * 60 + 59;
        let max = 0;
        this.timeSlots.forEach(ts => {
            min = Math.min(min, ts.start.hour() * 60 + ts.start.minute());
            max = Math.max(max, ts.end.hour() * 60 + ts.end.minute());
        });
        return {
            start: moment.utc(moment.duration(min, "minutes").asMilliseconds()).format("HH:mm"),
            end: moment.utc(moment.duration(max, "minutes").asMilliseconds()).format("HH:mm"),
        };
    }

    /**
     * Gets the day range, i.e. the first and last day a timeslot occurs.
     * @returns {{start: *, end: *}}
     */
    get range() {
        let min = moment().add(10, "years");
        let max = moment().subtract(10, "years");
        this.timeSlots.forEach(ts => {
            min = moment.min(min, ts.start.clone().startOf("day"));
            max = moment.max(max, ts.end.clone().startOf("day"));
        });
        return {
            start: min,
            end: max.add(1, "days"),
        };
    }
}

/**
 * A time slot
 */
class TimeSlot {
    static nextId = 1;

    /**
     * Create a time slot from a DTO.
     * @param dto The DTO to convert
     * @param displayRequests Whether to keep track of requests in the timeslot
     * @returns {TimeSlot} The created timeslot
     */
    static fromDTO(dto, displayRequests = false) {
        let requests = displayRequests
            ? dto.requests.map(r => {
                  return {
                      id: r.id,
                  };
              })
            : [];
        let id = dto.id === undefined ? undefined : dto.id.toString();
        return new TimeSlot(id, moment(dto.slot.opensAt), moment(dto.slot.closesAt), dto.capacity, requests, {});
    }

    /**
     * Creates a timeslot.
     *
     * Possible options:
     *  - preventEditing: if true, this timeslot cannot be edited
     *  - preventSelecting: if true, this timeslot cannot be selected
     *  - untakable: if true, display this timeslot as unavailable
     *  - showRequests: if true, display the amount of requests
     *  - showCapacity: if true, display the capacity
     *  - synched: if true, sync edits with the server
     *
     * @param id The ID of the timeslot, if undefined, generates an ID
     * @param start The start of the timeslot
     * @param end The end of the timeslot
     * @param capacity The capacity of the timeslot
     * @param requests The requests in the timeslot
     * @param options Optional configuration.
     */
    constructor(id, start, end, capacity, requests, options) {
        this.id = id === undefined ? (TimeSlot.nextId++).toString() : id;
        this.start = start;
        this.end = end;
        this._capacity = capacity;
        this.requests = requests;
        this.selected = false;
        this.timeSlots = undefined;

        this.preventEditing = options.preventEditing;
        this.preventSelecting = options.preventSelecting;
        this.untakable = options.untakable;
        this.showRequests = options.showRequests;
        this.showCapacity = options.showCapacity;
        this.synched = options.synched;
    }

    /**
     * Converts this timeslot to an event for FullCalendar.
     * @returns {*[]}
     */
    toEvent() {
        let title = "Time slot";
        if (this.showRequests && this.showCapacity) {
            title += ` (${this.requests.length}/${this.capacity})`;
        } else if (this.showCapacity) {
            title += ` (${this.capacity})`;
        }
        let result = {
            id: this.id,
            title: title,
            start: this.start.toDate(),
            end: this.end.toDate(),
            backgroundColor: this.colours.background,
            borderColor: this.colours.background,
            textColor: this.colours.text,
            classNames: this.disabled ? ["disabled"] : [],
        };
        if (this.preventEditing) {
            result.editable = false;
        }
        return result;
    }

    /**
     * Convert this timeslot to a set of HTML form inputs.
     * @param index The index in the list of timeslots
     * @returns {HTMLDivElement}
     */
    toInputs(index) {
        const div = document.createElement("div");
        const capacity = document.createElement("input");
        capacity.setAttribute("type", "hidden");
        capacity.setAttribute("name", `timeSlots[${index}].capacity`);
        capacity.setAttribute("value", this.capacity);
        div.appendChild(capacity);
        const start = document.createElement("input");
        start.setAttribute("type", "hidden");
        start.setAttribute("name", `timeSlots[${index}].slot.opensAt`);
        start.setAttribute("value", this.start.format("YYYY-MM-DDTHH:mm"));
        div.appendChild(start);
        const end = document.createElement("input");
        end.setAttribute("type", "hidden");
        end.setAttribute("name", `timeSlots[${index}].slot.closesAt`);
        end.setAttribute("value", this.end.format("YYYY-MM-DDTHH:mm"));
        div.appendChild(end);
        return div;
    }

    /**
     * Gets the colours to display this timeslot with
     * @returns {{background: string, text: string}}
     */
    get colours() {
        if (this.selected) {
            return {
                background: "var(--primary-active-colour)",
                text: "var(--on-primary-colour)",
            };
        }
        if (this.untakable) {
            return {
                background: "var(--disabled-colour)",
                text: "var(--on-disabled-colour)",
            };
        }
        return {
            background: "var(--primary-colour)",
            text: "var(--on-primary-colour)",
        };
    }

    /**
     * Sets the start and end time
     * @param {{start: *, end: *}} slot The new slot
     */
    set slot(slot) {
        this.start = slot.start;
        this.end = slot.end;
        this.timeSlots.update();
        this.sendUpdate();
    }

    /**
     * Gets the capacity of this timeslot
     * @returns {number} The capacity
     */
    get capacity() {
        return this._capacity;
    }

    /**
     * Sets the capacity of this timeslot
     * @param {number} capacity
     */
    set capacity(capacity) {
        this._capacity = capacity;
        if (this.timeSlots) {
            this.timeSlots.update();
            this.sendUpdate();
        }
    }

    /**
     * Removes this timeslot
     */
    remove() {
        this.timeSlots.update();
        this.sendRemoval();
    }

    /**
     * Sends an update to the server.
     */
    sendUpdate() {
        if (!this.synched) return;
        let headers = {
            "Content-Type": "application/json",
        };
        headers[document.querySelector("[name='_csrf_header']").getAttribute("content")] = document
            .querySelector("[name='_csrf']")
            .getAttribute("content");
        fetch(`/time-slot/${this.id}`, {
            method: "PATCH",
            headers: headers,
            body: JSON.stringify({
                slot: {
                    opensAt: this.start.format("YYYY-MM-DDTHH:mm"),
                    closesAt: this.end.format("YYYY-MM-DDTHH:mm"),
                },
                capacity: this.capacity,
            }),
        })
            .then(res => res.text())
            .catch(() => {
                alert("Something went wrong when updating time slots.");
                window.location.reload();
            });
    }

    /**
     * Sends a removal update to the server.
     */
    sendRemoval() {
        if (!this.synched) return;
        let headers = {
            "Content-Type": "application/json",
        };
        headers[document.querySelector("[name='_csrf_header']").getAttribute("content")] = document
            .querySelector("[name='_csrf']")
            .getAttribute("content");
        fetch(`/time-slot/${this.id}`, {
            method: "DELETE",
            headers: headers,
        })
            .then(res => res.text())
            .catch(() => {
                alert("Something went wrong when editing time slots.");
                window.location.reload();
            });
    }
}
