pragma Singleton

// Calendar state shared by the Daybook and the quiet bar capsule. GNOME's
// Evolution Data Server owns sync and credentials; the helper only presents a
// small, local JSON view of enabled event sources.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

    readonly property string helperPath: Quickshell.shellDir + "/scripts/calendar-agenda"

    // "loading" | "ready" | "degraded" | "unavailable"
    property string phase: "loading"
    readonly property bool available: (phase === "ready" || phase === "degraded") && sources.length > 0
    property var sources: []
    property var events: []
    property var errors: []
    property int generatedAt: 0

    property date selectedDate: new Date()
    readonly property string selectedDateKey: root.dateKey(selectedDate)
    readonly property var selectedEvents: root.eventsForDate(selectedDate)
    readonly property var nextEventForSelectedDate: root._nextForDate(selectedDate)

    property int rangeStart: 0
    property int rangeEnd: 0
    property bool fixtureMode: false
    property int fixtureNow: 0
    property bool watchWanted: false

    readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
    readonly property var nextEvent: {
        const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
        candidates.sort((a, b) => Number(a.start) - Number(b.start));
        return candidates.length > 0 ? candidates[0] : null;
    }
    readonly property int nextEventOffset: nextEvent ? Number(nextEvent.start) - nowEpoch : 999999
    readonly property bool capsuleVisible: nextEvent !== null && nextEventOffset <= 900 && nextEventOffset >= -300
    readonly property string capsuleText: {
        if (!root.capsuleVisible)
            return "";
        if (root.nextEventOffset <= 0)
            return "Now";
        return Math.ceil(root.nextEventOffset / 60) + "m";
    }

    SystemClock {
        id: clock
        precision: SystemClock.Minutes
    }

    function dateKey(date: date): string {
        return Qt.formatDate(date, "yyyy-MM-dd");
    }

    function dateFromKey(value: string): date {
        const parts = value.split("-").map(Number);
        if (parts.length !== 3 || parts.some(part => !Number.isFinite(part)))
            return new Date(NaN);
        return new Date(parts[0], parts[1] - 1, parts[2]);
    }

    function selectDate(date: date): void {
        if (!date || Number.isNaN(date.getTime()))
            return;
        root.selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
    }

    function selectIsoDate(value: string): void {
        root.selectDate(root.dateFromKey(value));
    }

    function sourceForId(sourceId: string): var {
        return root.sources.find(source => source.id === sourceId) ?? null;
    }

    function eventsForDate(date: date): var {
        if (!date || Number.isNaN(date.getTime()))
            return [];
        const start = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000);
        const end = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() / 1000);
        const matches = root.events.filter(event => Number(event.start) < end && Number(event.end) > start);
        matches.sort((a, b) => {
            if (!!a.allDay !== !!b.allDay)
                return a.allDay ? -1 : 1;
            if (Number(a.start) !== Number(b.start))
                return Number(a.start) - Number(b.start);
            return String(a.id).localeCompare(String(b.id));
        });
        return matches;
    }

    function markersForDate(date: date): var {
        const seen = ({});
        const markers = [];
        for (const event of root.eventsForDate(date)) {
            if (seen[event.sourceId])
                continue;
            seen[event.sourceId] = true;
            const source = root.sourceForId(event.sourceId);
            markers.push({ sourceId: event.sourceId, color: source?.color ?? "#82aaff" });
        }
        return markers;
    }

    function _nextForDate(date: date): var {
        const isToday = root.dateKey(date) === root.dateKey(clock.date);
        const candidates = root.eventsForDate(date).filter(event => {
            if (event.allDay)
                return false;
            return !isToday || Number(event.end) > root.nowEpoch;
        });
        return candidates.length > 0 ? candidates[0] : null;
    }

    function setVisibleMonth(year: int, month: int): void {
        const requestedStart = Math.floor(new Date(year, month, -6).getTime() / 1000);
        const requestedEnd = Math.floor(new Date(year, month, 43).getTime() / 1000);
        if (requestedStart >= root.rangeStart && requestedEnd <= root.rangeEnd)
            return;
        root.rangeStart = requestedStart;
        root.rangeEnd = requestedEnd;
        root._restartWatch();
    }

    function refresh(): void {
        if (!root.fixtureMode)
            root._restartWatch();
    }

    function _restartWatch(): void {
        if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
            return;
        root.phase = root.events.length > 0 ? root.phase : "loading";
        root.watchWanted = false;
        startTimer.restart();
    }

    function consumeSnapshot(data: string): void {
        if (root.fixtureMode || data.trim() === "")
            return;
        try {
            const snapshot = JSON.parse(data);
            if (snapshot.ok !== true) {
                root.phase = "unavailable";
                root.errors = snapshot.errors ?? [];
                return;
            }
            root.sources = Array.isArray(snapshot.sources) ? snapshot.sources : [];
            root.events = Array.isArray(snapshot.events) ? snapshot.events : [];
            root.errors = Array.isArray(snapshot.errors) ? snapshot.errors : [];
            root.generatedAt = Number(snapshot.generatedAt) || 0;
            root.phase = root.errors.length > 0 ? "degraded" : "ready";
        } catch (error) {
            root.phase = "unavailable";
            root.errors = [{ code: "invalid-snapshot" }];
        }
    }

    function openEvent(event: var): void {
        if (!event?.uid)
            return;
        Quickshell.execDetached(["gnome-calendar", "--uuid", String(event.uid)]);
    }

    function openCalendar(date: date): void {
        const target = date && !Number.isNaN(date.getTime()) ? date : root.selectedDate;
        // GNOME Calendar passes this value to g_date_time_new_from_iso8601(),
        // which requires a time and timezone rather than a bare YYYY-MM-DD.
        Quickshell.execDetached(["gnome-calendar", "--date", target.toISOString()]);
    }

    function join(event: var): void {
        const url = String(event?.joinUrl ?? "");
        if (url.startsWith("https://"))
            Quickshell.execDetached(["xdg-open", url]);
    }

    function applyFixture(name: string): void {
        if (name !== "daybook" && name !== "upcoming" && name !== "outside-window")
            return;

        root.watchWanted = false;
        root.fixtureMode = true;
        root.fixtureNow = Math.floor(new Date(2026, 7, 17, 11, 6).getTime() / 1000);

        if (name === "upcoming" || name === "outside-window") {
            const offset = name === "upcoming" ? 12 * 60 : 20 * 60;
            root.sources = [{ id: "fixture-work", name: "Fixture Work", color: "#82aaff" }];
            root.events = [
                {
                    id: "fixture-work:timed:1", sourceId: "fixture-work", uid: "fixture-timed",
                    summary: "Fixture appointment", start: root.fixtureNow + offset,
                    end: root.fixtureNow + offset + 30 * 60, allDay: false,
                    location: "", joinUrl: ""
                },
                {
                    id: "fixture-work:all-day:1", sourceId: "fixture-work", uid: "fixture-all-day",
                    summary: "Fixture all day", start: Math.floor(new Date(2026, 7, 17).getTime() / 1000),
                    end: Math.floor(new Date(2026, 7, 18).getTime() / 1000), allDay: true,
                    location: "", joinUrl: ""
                }
            ];
            root.errors = [];
            root.generatedAt = root.fixtureNow;
            root.phase = "ready";
            root.selectIsoDate("2026-08-17");
            return;
        }

        root.sources = [
            { id: "fixture-work", name: "Fixture Work", color: "#82aaff" },
            { id: "fixture-cloud", name: "Fixture Cloud", color: "#c3e88d" },
            { id: "fixture-personal", name: "Fixture Personal", color: "#fca7ea" }
        ];
        root.events = [
            {
                id: "fixture-work:standup:1", sourceId: "fixture-work", uid: "fixture-standup",
                summary: "Fixture standup", start: Math.floor(new Date(2026, 7, 17, 11, 30).getTime() / 1000),
                end: Math.floor(new Date(2026, 7, 17, 12, 0).getTime() / 1000), allDay: false,
                location: "Fixture Meet", joinUrl: "https://meet.google.com/abc-defg-hij"
            },
            {
                id: "fixture-work:planning:1", sourceId: "fixture-work", uid: "fixture-planning",
                summary: "Fixture planning", start: Math.floor(new Date(2026, 7, 17, 13, 0).getTime() / 1000),
                end: Math.floor(new Date(2026, 7, 17, 13, 45).getTime() / 1000), allDay: false,
                location: "", joinUrl: ""
            },
            {
                id: "fixture-cloud:focus:1", sourceId: "fixture-cloud", uid: "fixture-focus",
                summary: "Fixture focus", start: Math.floor(new Date(2026, 7, 17, 14, 30).getTime() / 1000),
                end: Math.floor(new Date(2026, 7, 17, 16, 0).getTime() / 1000), allDay: false,
                location: "", joinUrl: ""
            },
            {
                id: "fixture-personal:dinner:1", sourceId: "fixture-personal", uid: "fixture-dinner",
                summary: "Fixture dinner", start: Math.floor(new Date(2026, 7, 17, 18, 30).getTime() / 1000),
                end: Math.floor(new Date(2026, 7, 17, 20, 0).getTime() / 1000), allDay: false,
                location: "Fixture place", joinUrl: ""
            },
            {
                id: "fixture-cloud:tomorrow:1", sourceId: "fixture-cloud", uid: "fixture-tomorrow",
                summary: "Fixture tomorrow", start: Math.floor(new Date(2026, 7, 18, 9, 0).getTime() / 1000),
                end: Math.floor(new Date(2026, 7, 18, 10, 0).getTime() / 1000), allDay: false,
                location: "", joinUrl: ""
            }
        ];
        root.errors = [];
        root.generatedAt = root.fixtureNow;
        root.phase = "ready";
        root.selectIsoDate("2026-08-17");
    }

    function clearFixture(): void {
        root.fixtureMode = false;
        root.fixtureNow = 0;
        root.sources = [];
        root.events = [];
        root.errors = [];
        root.selectedDate = new Date();
        root.phase = "loading";
        root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
        root._restartWatch();
    }

    Process {
        id: watchProc
        command: [root.helperPath, "watch", String(root.rangeStart), String(root.rangeEnd)]
        running: root.watchWanted && !root.fixtureMode && root.rangeStart > 0
        stdout: SplitParser {
            onRead: data => root.consumeSnapshot(data)
        }
        onExited: (code, status) => {
            if (root.watchWanted && !root.fixtureMode)
                restartTimer.restart();
        }
    }

    Timer {
        id: startTimer
        interval: 40
        onTriggered: root.watchWanted = !root.fixtureMode
    }

    Timer {
        id: restartTimer
        interval: 10000
        onTriggered: root._restartWatch()
    }

    Component.onCompleted: {
        root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
    }
}
