pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// The freedesktop notification server, plus the two lists the UI renders:
//
//   popups  — what Toasts.qml is currently showing (transient, timed)
//   history — GNOME's message tray, what NotificationList.qml shows
//
// A notification lives exactly as long as `tracked` is true, so history holds
// the *live* objects rather than copies: that keeps actions and inline replies
// working from the tray, which is what GNOME does. The cost is that a
// notification an app closes itself (progress bars, "download finished"
// replacing "downloading") disappears from history too — correct behavior,
// but the reason history is not append-only.
// ─────────────────────────────────────────────────────────────────────────────

import Quickshell
import Quickshell.Services.Notifications
import QtQuick
import qs.config

Singleton {
    id: root

    readonly property alias server: server

    // Live tracked set, straight from the server. Mostly useful for counting;
    // the UI wants `popups` / `history`, which are ordered newest-first.
    readonly property alias active: server.trackedNotifications

    property var history: []
    property var popups: []

    // Suppresses toasts entirely. Notifications still reach history.
    property bool doNotDisturb: false

    // Cleared when the notification center is opened. The bar binds to this.
    property int unreadCount: 0

    // Kept separate from the persisted map so this version can safely run
    // before the matching schema entry lands. A later accepted write folds the
    // complete map into DesktopPreferences and clears this fallback.
    property var fallbackAppRules: ({})

    // Display metadata is intentionally session-only. The durable shape stays
    // just the per-application rule map, while a fresh notification gives the
    // settings page a human-readable name straight away.
    property var rememberedApplications: ({})

    readonly property var persistedAppRules: {
        const stored = DesktopPreferences.get("notificationAppRules");
        return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
    }

    // The schema change is the persistence boundary. This branch keeps a
    // session fallback only so it remains usable while that companion change
    // is being integrated; it intentionally makes no restart guarantee then.
    readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules")

    readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)

    readonly property var applications: {
        // byId()/heuristicLookup() do not make a binding by themselves. This
        // read updates persisted app labels once DesktopEntries finishes scan.
        const entries = DesktopEntries.applications.values;
        const remembered = root.rememberedApplications;
        return Object.keys(root.appRules).map(appId => ({
            id: appId,
            name: root.applicationLabel(appId, entries, remembered)
        })).sort((a, b) => a.name.localeCompare(b.name));
    }

    // Arrival times, keyed by notification id — the protocol carries no
    // timestamp. Deliberately formatted once at arrival rather than shown as
    // "5 minutes ago", which would need a clock ticking behind every card.
    readonly property var arrivals: ({})

    function timeText(id: int): string {
        const at = root.arrivals[id];
        return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
    }

    // Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
    // and the no-display expiry a transient notification gets while Do Not
    // Disturb is on (below). Critical urgency and an explicit expireTimeout
    // override policy; -1 ("server decides") falls back to it. 0 means "never
    // auto-expire" per spec.
    function notificationTimeoutMs(notification: var): int {
        if (notification.urgency === NotificationUrgency.Critical)
            return Settings.notificationTimeoutCriticalMs;
        if (notification.expireTimeout === 0)
            return 0;
        if (notification.expireTimeout > 0)
            return Math.round(notification.expireTimeout * 1000);
        return Settings.notificationTimeoutMs;
    }

    readonly property bool hasNotifications: root.history.length > 0

    function notificationAppId(notification: var): string {
        const desktopEntry = String(notification.desktopEntry ?? "").trim();
        return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
    }

    function applicationLabel(appId: string, entries: var, remembered: var): string {
        const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
        const entry = DesktopEntries.byId(appId)
            || DesktopEntries.byId(desktopId)
            || DesktopEntries.heuristicLookup(appId)
            || DesktopEntries.heuristicLookup(desktopId);
        return entry?.name || remembered[appId]?.name || appId;
    }

    function normalizedAppRule(rule: var): var {
        const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
        return {
            enabled: source.enabled !== false,
            showOnLockScreen: source.showOnLockScreen !== false,
            showContentOnLockScreen: source.showContentOnLockScreen !== false
        };
    }

    function appRule(appId: string): var {
        return root.normalizedAppRule(root.appRules[appId]);
    }

    function setAppRule(appId: string, patch: var): bool {
        if (!appId)
            return false;

        const current = root.appRule(appId);
        const next = {};
        for (const knownAppId of Object.keys(root.appRules))
            next[knownAppId] = root.appRule(knownAppId);
        next[appId] = {
            enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
            showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
            showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
        };

        if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
            root.fallbackAppRules = {};
        else
            root.fallbackAppRules = next;
        return true;
    }

    function rememberApplication(notification: var): string {
        const appId = root.notificationAppId(notification);
        const next = Object.assign({}, root.rememberedApplications);
        next[appId] = {
            name: String(notification.appName ?? "").trim() || appId
        };
        root.rememberedApplications = next;

        if (root.appRules[appId] === undefined)
            root.setAppRule(appId, {});
        return appId;
    }

    // These policy getters deliberately accept Notification objects, so a lock
    // screen can use the same source of truth without duplicating app matching.
    function shouldShowOnLockScreen(notification: var): bool {
        const rule = root.appRule(root.notificationAppId(notification));
        return rule.enabled && rule.showOnLockScreen;
    }

    function shouldShowContentOnLockScreen(notification: var): bool {
        const rule = root.appRule(root.notificationAppId(notification));
        return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
    }

    // history grouped by app, in most-recent-app-first order — the shape
    // NotificationList.qml renders directly.
    readonly property var groups: {
        const out = [];
        const byApp = {};
        for (const n of root.history) {
            const key = n.appName || "Notifications";
            let group = byApp[key];
            if (!group) {
                group = {
                    app: key,
                    icon: n.appIcon,
                    desktopEntry: n.desktopEntry,
                    items: []
                };
                byApp[key] = group;
                out.push(group);
            }
            group.items.push(n);
        }
        return out;
    }

    NotificationServer {
        id: server

        keepOnReload: true
        persistenceSupported: true
        bodySupported: true
        bodyMarkupSupported: true
        bodyImagesSupported: true
        imageSupported: true
        actionsSupported: true
        actionIconsSupported: true
        inlineReplySupported: true

        onNotification: notification => root.handleNotification(notification)
    }

    function handleNotification(notification: var): void {
            // Replayed from before a shell reload. Letting these through would
            // re-toast and re-list everything on every edit, so they are left
            // untracked and allowed to die.
            if (notification.lastGeneration)
                return;

            const appId = root.rememberApplication(notification);
            if (!root.appRule(appId).enabled)
                return;

            // Without this the object is destroyed the instant this returns.
            notification.tracked = true;
            root.arrivals[notification.id] = new Date();

            // The object may go away at any time (app-side close, dismiss()).
            // Drop our references synchronously when it does.
            notification.closed.connect(() => root.forget(notification));

            // `transient` is the volume-OSD case: show it, never file it.
            if (!notification.transient) {
                root.pushHistory(notification);
                root.unreadCount += 1;
            }

            // An exception belongs to the focus mode that is in force. A Do Not
            // Disturb switched on by hand has no exceptions and stays absolute,
            // because FocusModes.allows is false whenever no mode is active.
            if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
                root.popups = [notification].concat(root.popups);
            } else if (notification.transient) {
                // Never shown, and (being transient) never filed in history
                // either — nothing will otherwise dismiss() it, so schedule
                // the same release its popup timeout would have given it.
                root.scheduleTransientExpiry(notification);
            }
    }

    // Runs a DND-hidden transient notification through the same lifetime it
    // would have gotten as a visible popup (Toast.qml's countdown), just
    // without ever showing it, so it still gets released instead of staying
    // tracked forever.
    function scheduleTransientExpiry(notification: var): void {
        const ms = root.notificationTimeoutMs(notification);
        if (ms <= 0)
            return;

        const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root);
        timer.interval = ms;
        timer.triggered.connect(() => {
            timer.destroy();
            root.releaseTransient(notification);
        });

        // Closed some other way first (app-side close, dismissAll()) — cancel
        // the pending timer instead of firing a stale dismiss() later.
        notification.closed.connect(() => timer.destroy());
        timer.running = true;
    }

    // Transient notifications are never filed in history, so nothing else
    // holds a reference once their lifetime ends — release tracked state
    // directly. Shared by the DND-hidden expiry above and dismissAll() below.
    function releaseTransient(n: var): void {
        if (n.transient)
            n.dismiss();
    }

    // ── Mutation ────────────────────────────────────────────────────────────

    function pushHistory(n: var): void {
        const next = [n].concat(root.history);

        // Anything past the cap is released, otherwise it stays tracked
        // forever and the server's set grows without bound.
        const evicted = next.splice(Settings.notificationHistoryLimit);
        root.history = next;
        for (const old of evicted)
            old.dismiss();
    }

    // Hide a toast without filing the notification away as read. Mirrors
    // GNOME: closing a banner leaves it in the tray.
    function dropPopup(n: Notification): void {
        const next = root.popups.filter(x => x !== n);
        if (next.length === root.popups.length)
            return;
        root.popups = next;
        root.releaseTransient(n);
    }

    function dismiss(n: Notification): void {
        n.dismiss();
    }

    function dismissAll(): void {
        // Copy first: dismiss() re-enters through forget() and rewrites both
        // lists while we iterate.
        const all = root.history.slice();

        // Transient notifications are never filed in history — whether
        // currently shown as a popup or hidden by Do Not Disturb with a
        // pending scheduleTransientExpiry() timer — so releasing them needs
        // its own pass over the server's full tracked set.
        const transients = root.active.values.filter(n => n.transient);

        root.history = [];
        root.popups = [];
        for (const n of all)
            n.dismiss();
        for (const n of transients)
            root.releaseTransient(n);
        root.unreadCount = 0;
    }

    function dismissApp(appName: string): void {
        const doomed = root.history.filter(n => (n.appName || "Notifications") === appName);
        root.history = root.history.filter(n => doomed.indexOf(n) === -1);
        root.popups = root.popups.filter(n => doomed.indexOf(n) === -1);
        for (const n of doomed)
            n.dismiss();
    }

    function markAllRead(): void {
        root.unreadCount = 0;
    }

    // Called from the `closed` signal — the object is on its way out, so this
    // only ever removes references, never touches the notification.
    function forget(n: var): void {
        delete root.arrivals[n.id];
        if (root.history.indexOf(n) !== -1)
            root.history = root.history.filter(x => x !== n);
        if (root.popups.indexOf(n) !== -1)
            root.popups = root.popups.filter(x => x !== n);
    }
}
