import type { Calendar, DateSelectArg, DatesSetArg, EventChangeArg, EventDropArg, EventInput, EventSourceFunc, EventSourceFuncArg, EventSourceInput, PluginDef } from "@fullcalendar/core";
import froca from "../../services/froca.js";
import ViewMode, { type ViewModeArgs } from "./view_mode.js";
import type FNote from "../../entities/fnote.js";
import server from "../../services/server.js";
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import dialogService from "../../services/dialog.js";
import attributes from "../../services/attributes.js";
import type { EventData } from "../../components/app_context.js";
import utils from "../../services/utils.js";
import date_notes from "../../services/date_notes.js";
import appContext from "../../components/app_context.js";
import type { EventImpl } from "@fullcalendar/core/internal";
import debounce, { type DebouncedFunction } from "debounce";

const TPL = `
<div class="calendar-view">
    <style>
    .calendar-view {
        overflow: hidden;
        position: relative;
        height: 100%;
        user-select: none;
        padding: 10px;
    }

    .calendar-view a {
        color: unset;
    }

    .calendar-container {
        height: 100%;
        --fc-page-bg-color: var(--main-background-color);
        --fc-border-color: var(--main-border-color);
        --fc-neutral-bg-color: var(--launcher-pane-background-color);
        --fc-list-event-hover-bg-color: var(--left-pane-item-hover-background);
    }

    .calendar-container .fc-toolbar.fc-header-toolbar {
        margin-bottom: 0.5em;
    }

    .calendar-container .fc-list-sticky .fc-list-day > * {
        z-index: 50;
    }

    body.desktop:not(.zen) .calendar-container .fc-toolbar.fc-header-toolbar {
        padding-right: 5em;
    }

    .calendar-container .fc-toolbar-title {
        font-size: 1.3rem;
        font-weight: normal;
    }

    .calendar-container a.fc-event {
        text-decoration: none;
    }

    .calendar-container .fc-button {
        padding: 0.2em 0.5em;
    }

    .calendar-container .promoted-attribute {
        font-size: 0.85em;
        opacity: 0.85;
    }
    </style>

    <div class="calendar-container">
    </div>
</div>
`;

// TODO: Deduplicate
interface CreateChildResponse {
    note: {
        noteId: string;
    };
}

const CALENDAR_VIEWS = [
    "timeGridWeek",
    "dayGridMonth",
    "multiMonthYear",
    "listMonth"
]

export default class CalendarView extends ViewMode {

    private $root: JQuery<HTMLElement>;
    private $calendarContainer: JQuery<HTMLElement>;
    private noteIds: string[];
    private parentNote: FNote;
    private calendar?: Calendar;
    private isCalendarRoot: boolean;
    private lastView?: string;
    private debouncedSaveView?: DebouncedFunction<() => void>;

    constructor(args: ViewModeArgs) {
        super(args);

        this.$root = $(TPL);
        this.$calendarContainer = this.$root.find(".calendar-container");
        this.noteIds = args.noteIds;
        this.parentNote = args.parentNote;
        this.isCalendarRoot = false;
        args.$parent.append(this.$root);
    }

    get isFullHeight(): boolean {
        return true;
    }

    async renderList(): Promise<JQuery<HTMLElement> | undefined> {
        this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot");
        const isEditable = !this.isCalendarRoot;

        const { Calendar } = await import("@fullcalendar/core");
        const plugins: PluginDef[] = [];
        plugins.push((await import("@fullcalendar/daygrid")).default);
        plugins.push((await import("@fullcalendar/timegrid")).default);
        plugins.push((await import("@fullcalendar/list")).default);
        plugins.push((await import("@fullcalendar/multimonth")).default);
        if (isEditable || this.isCalendarRoot) {
            plugins.push((await import("@fullcalendar/interaction")).default);
        }

        let eventBuilder: EventSourceFunc;
        if (!this.isCalendarRoot) {
            eventBuilder = async () => await CalendarView.buildEvents(this.noteIds)
        } else {
            eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e);
        }

        // Parse user's initial view, if valid.
        let initialView = "dayGridMonth";
        const userInitialView = this.parentNote.getLabelValue("calendar:view");
        if (userInitialView && CALENDAR_VIEWS.includes(userInitialView)) {
            initialView = userInitialView;
        }

        const calendar = new Calendar(this.$calendarContainer[0], {
            plugins,
            initialView,
            events: eventBuilder,
            editable: isEditable,
            selectable: isEditable,
            select: (e) => this.#onCalendarSelection(e),
            eventChange: (e) => this.#onEventMoved(e),
            firstDay: options.getInt("firstDayOfWeek") ?? 0,
            weekends: !this.parentNote.hasAttribute("label", "calendar:hideWeekends"),
            weekNumbers: this.parentNote.hasAttribute("label", "calendar:weekNumbers"),
            locale: await CalendarView.#getLocale(),
            height: "100%",
            nowIndicator: true,
            eventContent: (e) => {
                let html = "";
                const { iconClass, promotedAttributes } = e.event.extendedProps;

                // Title and icon
                if (iconClass) {
                    html += `<span class="${iconClass}"></span> `;
                }
                html += utils.escapeHtml(e.event.title);

                // Promoted attributes
                if (promotedAttributes) {
                    for (const [name, value] of promotedAttributes) {
                        html += `\
                        <div class="promoted-attribute">
                            <span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
                        </div>`;
                    }
                }

                return { html };
            },
            dateClick: async (e) => {
                if (!this.isCalendarRoot) {
                    return;
                }

                const note = await date_notes.getDayNote(e.dateStr);
                if (note) {
                    appContext.tabManager.getActiveContext()?.setNote(note.noteId);
                }
            },
            datesSet: (e) => this.#onDatesSet(e),
            headerToolbar: {
                start: "title",
                end: `${CALENDAR_VIEWS.join(",")} today prev,next`
            }
        });
        calendar.render();
        this.calendar = calendar;

        return this.$root;
    }

    static async #getLocale() {
        const locale = options.get("locale");

        // Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages.
        switch (locale) {
            case "de":
                return (await import("@fullcalendar/core/locales/de")).default;
            case "es":
                return (await import("@fullcalendar/core/locales/es")).default;
            case "fr":
                return (await import("@fullcalendar/core/locales/fr")).default;
            case "cn":
                return (await import("@fullcalendar/core/locales/zh-cn")).default;
            case "tw":
                return (await import("@fullcalendar/core/locales/zh-tw")).default;
            case "ro":
                return (await import("@fullcalendar/core/locales/ro")).default;
            case "en":
            default:
                return undefined;
        }
    }

    #onDatesSet(e: DatesSetArg) {
        const currentView = e.view.type;
        if (currentView === this.lastView) {
            return;
        }

        if (!this.debouncedSaveView) {
            this.debouncedSaveView = debounce(() => {
                if (this.lastView) {
                    attributes.setLabel(this.parentNote.noteId, "calendar:view", this.lastView);
                }
            }, 1_000);
        }

        this.debouncedSaveView();
        this.lastView = currentView;
    }

    async #onCalendarSelection(e: DateSelectArg) {
        // Handle start and end date
        const { startDate, endDate } = this.#parseStartEndDateFromEvent(e);
        if (!startDate) {
            return;
        }

        // Handle start and end time.
        const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e);

        // Ask for the title
        const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
        if (!title?.trim()) {
            return;
        }

        // Create the note.
        const { note } = await server.post<CreateChildResponse>(`notes/${this.parentNote.noteId}/children?target=into`, {
            title,
            content: "",
            type: "text"
        });

        // Set the attributes.
        attributes.setLabel(note.noteId, "startDate", startDate);
        if (endDate) {
            attributes.setLabel(note.noteId, "endDate", endDate);
        }
        if (startTime) {
            attributes.setLabel(note.noteId, "startTime", startTime);
        }
        if (endTime) {
            attributes.setLabel(note.noteId, "endTime", endTime);
        }
    }

    #parseStartEndDateFromEvent(e: DateSelectArg | EventImpl) {
        const startDate = CalendarView.#formatDateToLocalISO(e.start);
        if (!startDate) {
            return { startDate: null, endDate: null };
        }
        let endDate;
        if (e.allDay) {
            endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1));
        } else {
            endDate = CalendarView.#formatDateToLocalISO(e.end);
        }
        return { startDate, endDate };
    }

    #parseStartEndTimeFromEvent(e: DateSelectArg | EventImpl) {
        let startTime = null;
        let endTime = null;
        if (!e.allDay) {
            startTime = CalendarView.#formatTimeToLocalISO(e.start);
            endTime = CalendarView.#formatTimeToLocalISO(e.end);
        }

        return { startTime, endTime };
    }

    async #onEventMoved(e: EventChangeArg) {
        // Handle start and end date
        let { startDate, endDate } = this.#parseStartEndDateFromEvent(e.event);
        if (!startDate) {
            return;
        }
        const noteId = e.event.extendedProps.noteId;

        // Don't store the end date if it's empty.
        if (endDate === startDate) {
            endDate = undefined;
        }

        // Update start date
        const note = await froca.getNote(noteId);
        if (!note) {
            return;
        }

        // Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the
        // attributes to be effectively updated
        const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate";
        const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate";

        attributes.setAttribute(note, "label", startAttribute, startDate);
        attributes.setAttribute(note, "label", endAttribute, endDate);

        // Update start time and end time if needed.
        if (!e.event.allDay) {
            const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
            const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";

            const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e.event);
            attributes.setAttribute(note, "label", startAttribute, startTime);
            attributes.setAttribute(note, "label", endAttribute, endTime);
        }
    }

    onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
        // Refresh note IDs if they got changed.
        if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) {
            this.noteIds = this.parentNote.getChildNoteIds();
        }

        // Refresh calendar on attribute change.
        if (loadResults.getAttributeRows().some((attribute) => attribute.noteId === this.parentNote.noteId && attribute.name?.startsWith("calendar:") && attribute.name !== "calendar:view")) {
            return true;
        }

        // Refresh dataset on subnote change.
        if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
            this.calendar.refetchEvents();
        }
    }

    async #buildEventsForCalendar(e: EventSourceFuncArg) {
        const events = [];

        // Gather all the required date note IDs.
        const dateRange = utils.getMonthsInDateRange(e.startStr, e.endStr);
        let allDateNoteIds: string[] = [];
        for (const month of dateRange) {
            // TODO: Deduplicate get type.
            const dateNotesForMonth = await server.get<Record<string, string>>(`special-notes/notes-for-month/${month}?calendarRoot=${this.parentNote.noteId}`);
            const dateNoteIds = Object.values(dateNotesForMonth);
            allDateNoteIds = [...allDateNoteIds, ...dateNoteIds];
        }

        // Request all the date notes.
        const dateNotes = await froca.getNotes(allDateNoteIds);
        const childNoteToDateMapping: Record<string, string> = {};
        for (const dateNote of dateNotes) {
            const startDate = dateNote.getLabelValue("dateNote");
            if (!startDate) {
                continue;
            }

            events.push(await CalendarView.buildEvent(dateNote, { startDate }));

            if (dateNote.hasChildren()) {
                const childNoteIds = dateNote.getChildNoteIds();
                for (const childNoteId of childNoteIds) {
                    childNoteToDateMapping[childNoteId] = startDate;
                }
            }
        }

        // Request all child notes of date notes in a single run.
        const childNoteIds = Object.keys(childNoteToDateMapping);
        const childNotes = await froca.getNotes(childNoteIds);
        for (const childNote of childNotes) {
            const startDate = childNoteToDateMapping[childNote.noteId];
            const event = await CalendarView.buildEvent(childNote, { startDate });
            events.push(event);
        }

        return events.flat();
    }

    static async buildEvents(noteIds: string[]) {
        const notes = await froca.getNotes(noteIds);
        const events: EventSourceInput = [];

        for (const note of notes) {
            const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate");

            if (note.hasChildren()) {
                const childrenEventData = await this.buildEvents(note.getChildNoteIds());
                if (childrenEventData.length > 0) {
                    events.push(childrenEventData);
                }
            }

            if (!startDate) {
                continue;
            }

            const endDate = CalendarView.#getCustomisableLabel(note, "endDate", "calendar:endDate");
            const startTime = CalendarView.#getCustomisableLabel(note, "startTime", "calendar:startTime");
            const endTime = CalendarView.#getCustomisableLabel(note, "endTime", "calendar:endTime");
            events.push(await CalendarView.buildEvent(note, { startDate, endDate, startTime, endTime }));
        }

        return events.flat();
    }

    /**
     * Allows the user to customize the attribute from which to obtain a particular value. For example, if `customLabelNameAttribute` is `calendar:startDate`
     * and `defaultLabelName` is `startDate` and the note at hand has `#calendar:startDate=myStartDate #myStartDate=2025-02-26` then the value returned will
     * be `2025-02-26`. If there is no custom attribute value, then the value of the default attribute is returned instead (e.g. `#startDate`).
     *
     * @param note the note from which to read the values.
     * @param defaultLabelName the name of the label in case a custom value is not found.
     * @param customLabelNameAttribute the name of the label to look for a custom value.
     * @returns the value of either the custom label or the default label.
     */
    static #getCustomisableLabel(note: FNote, defaultLabelName: string, customLabelNameAttribute: string) {
        const customAttributeName = note.getLabelValue(customLabelNameAttribute);
        if (customAttributeName) {
            const customValue = note.getLabelValue(customAttributeName);
            if (customValue) {
                return customValue;
            }
        }

        return note.getLabelValue(defaultLabelName);
    }

    static async buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: {
            startDate: string,
            endDate?: string | null,
            startTime?: string | null,
            endTime?: string | null
        }) {
        const customTitleAttributeName = note.getLabelValue("calendar:title");
        const titles = await CalendarView.#parseCustomTitle(customTitleAttributeName, note);
        const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color");
        const events: EventInput[] = [];

        const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(",");
        let displayedAttributesData: Array<[string, string]> | null = null;
        if (calendarDisplayedAttributes) {
            displayedAttributesData = await this.#buildDisplayedAttributes(note, calendarDisplayedAttributes);
        }

        for (const title of titles) {
            if (startTime && endTime && !endDate) {
                endDate = startDate;
            }

            startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
            if (!startTime) {
                const endDateOffset = CalendarView.#offsetDate(endDate ?? startDate, 1);
                if (endDateOffset) {
                    endDate = CalendarView.#formatDateToLocalISO(endDateOffset);
                }
            }

            endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
            const eventData: EventInput = {
                title: title,
                start: startDate,
                url: `#${note.noteId}`,
                noteId: note.noteId,
                color: color ?? undefined,
                iconClass: note.getLabelValue("iconClass"),
                promotedAttributes: displayedAttributesData
            };
            if (endDate) {
                eventData.end = endDate;
            }
            events.push(eventData);
        }
        return events;
    }

    static async #buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
        const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
        const result: Array<[string, string]> = [];

        for (const attribute of filteredDisplayedAttributes) {
            if (attribute.type === "label") result.push([attribute.name, attribute.value]);
            else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
        }

        return result;
    }

    static async #parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise<string[]> {
        if (customTitlettributeName) {
            const labelValue = note.getAttributeValue("label", customTitlettributeName);
            if (labelValue) return [labelValue];

            if (allowRelations) {
                const relations = note.getRelations(customTitlettributeName);
                if (relations.length > 0) {
                    const noteIds = relations.map((r) => r.targetNoteId);
                    const notesFromRelation = await froca.getNotes(noteIds);
                    const titles = [];

                    for (const targetNote of notesFromRelation) {
                        const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title");
                        const targetTitles = await CalendarView.#parseCustomTitle(targetCustomTitleValue, targetNote, false);
                        titles.push(targetTitles.flat());
                    }

                    return titles.flat();
                }
            }
        }

        return [note.title];
    }

    static #formatDateToLocalISO(date: Date | null | undefined) {
        if (!date) {
            return undefined;
        }

        const offset = date.getTimezoneOffset();
        const localDate = new Date(date.getTime() - offset * 60 * 1000);
        return localDate.toISOString().split("T")[0];
    }

    static #formatTimeToLocalISO(date: Date | null | undefined) {
        if (!date) {
            return undefined;
        }

        const offset = date.getTimezoneOffset();
        const localDate = new Date(date.getTime() - offset * 60 * 1000);
        return localDate.toISOString()
            .split("T")[1]
            .substring(0, 5);
    }

    static #offsetDate(date: Date | string | null | undefined, offset: number) {
        if (!date) {
            return undefined;
        }

        const newDate = new Date(date);
        newDate.setDate(newDate.getDate() + offset);
        return newDate;
    }

}
