// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-

import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';

import * as Config from '../misc/config.js';
import * as Main from './main.js';
import * as MessageTray from './messageTray.js';

import {loadInterfaceXML} from '../misc/fileUtils.js';
import {NotificationErrors, NotificationError} from '../misc/dbusErrors.js';

const FdoNotificationsIface = loadInterfaceXML('org.freedesktop.Notifications');

/** @enum {number} */
const NotificationClosedReason = {
    EXPIRED: 1,
    DISMISSED: 2,
    APP_CLOSED: 3,
    UNDEFINED: 4,
};

/** @enum {number} */
const Urgency = {
    LOW: 0,
    NORMAL: 1,
    CRITICAL: 2,
};

class FdoNotificationDaemon {
    constructor() {
        this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(FdoNotificationsIface, this);
        this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/Notifications');

        this._sourcesForApp = new Map();
        this._sourceForPidAndName = new Map();
        this._notifications = new Map();

        this._nextNotificationId = 1;
    }

    _imageForNotificationData(hints) {
        if (hints['image-data']) {
            const [
                width, height, rowStride, hasAlpha,
                bitsPerSample, nChannels_, data,
            ] = hints['image-data'];
            return Shell.util_create_pixbuf_from_data(data,
                GdkPixbuf.Colorspace.RGB,
                hasAlpha,
                bitsPerSample,
                width,
                height,
                rowStride);
        } else if (hints['image-path']) {
            return this._iconForNotificationData(hints['image-path']);
        }
        return null;
    }

    _iconForNotificationData(icon) {
        if (icon) {
            if (icon.startsWith('file://'))
                return new Gio.FileIcon({file: Gio.File.new_for_uri(icon)});
            else if (icon.startsWith('/'))
                return new Gio.FileIcon({file: Gio.File.new_for_path(icon)});
            else
                return new Gio.ThemedIcon({name: icon});
        }
        return null;
    }

    _getApp(pid, appId, appName) {
        const appSys = Shell.AppSystem.get_default();
        let app;

        app = Shell.WindowTracker.get_default().get_app_from_pid(pid);
        if (!app && appId)
            app = appSys.lookup_app(`${appId}.desktop`);

        if (!app)
            app = appSys.lookup_app(`${appName}.desktop`);

        return app;
    }

    // Returns the source associated with an app.
    //
    // If no existing source is found a new one is created.
    _getSourceForApp(sender, app) {
        let source = this._sourcesForApp.get(app);

        if (source)
            return source;

        source = new FdoNotificationDaemonSource(sender, app);

        if (app) {
            this._sourcesForApp.set(app, source);
            source.connect('destroy', () => {
                this._sourcesForApp.delete(app);
            });
        }

        Main.messageTray.add(source);
        return source;
    }

    // Returns the source associated with a pid and the app name.
    //
    // If no existing source is found, a new one is created.
    _getSourceForPidAndName(sender, pid, appName) {
        const key = `${pid}${appName}`;
        let source = this._sourceForPidAndName.get(key);

        if (source)
            return source;

        source = new FdoNotificationDaemonSource(sender, null);

        // Only check whether we have a PID since it's enough to identify
        // uniquely an app and "" is a valid app name.
        if (pid) {
            this._sourceForPidAndName.set(key, source);
            source.connect('destroy', () => {
                this._sourceForPidAndName.delete(key);
            });
        }

        Main.messageTray.add(source);
        return source;
    }

    NotifyAsync(params, invocation) {
        let [appName, replacesId, appIcon, summary, body, actions, hints, timeout_] = params;
        let id;

        for (let hint in hints) {
            // unpack the variants
            hints[hint] = hints[hint].deepUnpack();
        }

        hints = {urgency: Urgency.NORMAL, ...hints};

        // Be compatible with the various hints for image data and image path
        // 'image-data' and 'image-path' are the latest name of these hints, introduced in 1.2

        if (!hints['image-path'] && hints['image_path'])
            hints['image-path'] = hints['image_path']; // version 1.1 of the spec

        if (!hints['image-data']) {
            if (hints['image_data'])
                hints['image-data'] = hints['image_data']; // version 1.1 of the spec
            else if (hints['icon_data'] && !hints['image-path'])
                // early versions of the spec; 'icon_data' should only be used if 'image-path' is not available
                hints['image-data'] = hints['icon_data'];
        }

        let source, notification;
        if (replacesId !== 0 && this._notifications.has(replacesId)) {
            notification = this._notifications.get(replacesId);
            source = notification.source;
            id = replacesId;
        } else {
            const sender = hints['x-shell-sender'];
            const pid = hints['x-shell-sender-pid'];
            const appId = hints['desktop-entry'];
            const app = this._getApp(pid, appId, appName);

            id = this._nextNotificationId++;
            source = app
                ? this._getSourceForApp(sender, app)
                : this._getSourceForPidAndName(sender, pid, appName);

            notification = new MessageTray.Notification({source});
            this._notifications.set(id, notification);
            notification.connect('destroy', (n, reason) => {
                this._notifications.delete(id);
                let notificationClosedReason;
                switch (reason) {
                case MessageTray.NotificationDestroyedReason.EXPIRED:
                    notificationClosedReason = NotificationClosedReason.EXPIRED;
                    break;
                case MessageTray.NotificationDestroyedReason.DISMISSED:
                    notificationClosedReason = NotificationClosedReason.DISMISSED;
                    break;
                case MessageTray.NotificationDestroyedReason.SOURCE_CLOSED:
                    notificationClosedReason = NotificationClosedReason.APP_CLOSED;
                    break;
                }
                this._emitNotificationClosed(id, notificationClosedReason);
            });
        }

        const gicon = this._imageForNotificationData(hints);

        const soundFile = 'sound-file' in hints
            ? Gio.File.new_for_path(hints['sound-file']) : null;

        notification.set({
            title: summary,
            body,
            gicon,
            useBodyMarkup: true,
            sound: new MessageTray.Sound(soundFile, hints['sound-name']),
            acknowledged: false,
        });
        notification.clearActions();

        let hasDefaultAction = false;

        if (actions.length) {
            for (let i = 0; i < actions.length - 1; i += 2) {
                let [actionId, label] = [actions[i], actions[i + 1]];
                if (actionId === 'default') {
                    hasDefaultAction = true;
                } else {
                    notification.addAction(label, () => {
                        this._emitActivationToken(source, id);
                        this._emitActionInvoked(id, actionId);
                    });
                }
            }
        }

        if (hasDefaultAction) {
            notification.connect('activated', () => {
                this._emitActivationToken(source, id);
                this._emitActionInvoked(id, 'default');
            });
        } else {
            notification.connect('activated', () => {
                source.open();
            });
        }

        switch (hints.urgency) {
        case Urgency.LOW:
            notification.urgency = MessageTray.Urgency.LOW;
            break;
        case Urgency.NORMAL:
            notification.urgency = MessageTray.Urgency.NORMAL;
            break;
        case Urgency.CRITICAL:
            notification.urgency = MessageTray.Urgency.CRITICAL;
            break;
        }
        notification.resident = !!hints.resident;
        // 'transient' is a reserved keyword in JS, so we have to retrieve the value
        // of the 'transient' hint with hints['transient'] rather than hints.transient
        notification.isTransient = !!hints['transient'];

        let privacyScope = hints['x-gnome-privacy-scope'] || 'user';
        notification.privacyScope = privacyScope === 'system'
            ? MessageTray.PrivacyScope.SYSTEM
            : MessageTray.PrivacyScope.USER;

        // Only fallback to 'app-icon' when the source doesn't have a valid app
        const sourceGIcon = source.app ? null : this._iconForNotificationData(appIcon);
        source.processNotification(notification, appName, sourceGIcon);

        return invocation.return_value(GLib.Variant.new('(u)', [id]));
    }

    CloseNotification(id) {
        const notification = this._notifications.get(id);
        notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
    }

    GetCapabilities() {
        return [
            'actions',
            // 'action-icons',
            'body',
            // 'body-hyperlinks',
            // 'body-images',
            'body-markup',
            // 'icon-multi',
            'icon-static',
            'persistence',
            'sound',
        ];
    }

    GetServerInformation() {
        return [
            Config.PACKAGE_NAME,
            'GNOME',
            Config.PACKAGE_VERSION,
            '1.2',
        ];
    }

    _emitNotificationClosed(id, reason) {
        this._dbusImpl.emit_signal('NotificationClosed',
            GLib.Variant.new('(uu)', [id, reason]));
    }

    _emitActionInvoked(id, action) {
        this._dbusImpl.emit_signal('ActionInvoked',
            GLib.Variant.new('(us)', [id, action]));
    }

    _emitActivationToken(source, id) {
        const context = global.create_app_launch_context(0, -1);
        const info = source.app?.get_app_info();
        if (info) {
            const token = context.get_startup_notify_id(info, []);
            this._dbusImpl.emit_signal('ActivationToken',
                GLib.Variant.new('(us)', [id, token]));
        }
    }
}

export const FdoNotificationDaemonSource = GObject.registerClass(
class FdoNotificationDaemonSource extends MessageTray.Source {
    constructor(sender, app) {
        super({
            policy: MessageTray.NotificationPolicy.newForApp(app),
        });

        this.app = app;
        this._appName = null;
        this._appIcon = null;

        if (sender) {
            this._nameWatcherId = Gio.DBus.session.watch_name(sender,
                Gio.BusNameWatcherFlags.NONE,
                null,
                this._onNameVanished.bind(this));
        } else {
            this._nameWatcherId = 0;
        }
    }

    _onNameVanished() {
        // Destroy the notification source when its sender is removed from DBus.
        // Only do so if this.app is set to avoid removing "notify-send" sources, senders
        // of which аre removed from DBus immediately.
        // Sender being removed from DBus would normally result in a tray icon being removed,
        // so allow the code path that handles the tray icon being removed to handle that case.
        if (this.app)
            this.destroy();
    }

    processNotification(notification, appName, appIcon) {
        if (!this.app && appName) {
            this._appName = appName;
            this.notify('title');
        }

        if (!this.app && appIcon) {
            this._appIcon = appIcon;
            this.notify('icon');
        }

        let tracker = Shell.WindowTracker.get_default();
        // Acknowledge notifications that are resident and their app has the
        // current focus so that we don't show a banner.
        if (notification.resident && this.app && tracker.focus_app === this.app)
            notification.acknowledged = true;

        this.addNotification(notification);
    }

    open() {
        this.openApp();
        this.destroyNonResidentNotifications();
    }

    openApp() {
        if (this.app == null)
            return;

        this.app.activate();
        Main.overview.hide();
        Main.panel.closeCalendar();
    }

    destroy() {
        if (this._nameWatcherId) {
            Gio.DBus.session.unwatch_name(this._nameWatcherId);
            this._nameWatcherId = 0;
        }

        super.destroy();
    }

    get title() {
        return this.app?.get_name() ?? this._appName;
    }

    get icon() {
        return this.app?.get_icon() ?? this._appIcon;
    }
});

const PRIORITY_URGENCY_MAP = {
    low: MessageTray.Urgency.LOW,
    normal: MessageTray.Urgency.NORMAL,
    high: MessageTray.Urgency.HIGH,
    urgent: MessageTray.Urgency.CRITICAL,
};

const GtkNotificationDaemonNotification = GObject.registerClass(
class GtkNotificationDaemonNotification extends MessageTray.Notification {
    constructor(source, id, notification) {
        super({source});
        this._serialized = GLib.Variant.new('a{sv}', notification);
        this.id = id;

        const {
            title,
            body,
            icon: gicon,
            urgent,
            priority,
            buttons,
            'default-action': defaultAction,
            'default-action-target': defaultActionTarget,
            timestamp: time,
        } = notification;

        if (priority) {
            let urgency = PRIORITY_URGENCY_MAP[priority.unpack()];
            this.urgency = urgency !== undefined ? urgency : MessageTray.Urgency.NORMAL;
        } else if (urgent) {
            this.urgency = urgent.unpack()
                ? MessageTray.Urgency.CRITICAL
                : MessageTray.Urgency.NORMAL;
        } else {
            this.urgency = MessageTray.Urgency.NORMAL;
        }

        if (buttons) {
            buttons.deepUnpack().forEach(button => {
                this.addAction(button.label.unpack(), () => {
                    this._onButtonClicked(button);
                });
            });
        }

        this._defaultAction = defaultAction?.unpack();
        this._defaultActionTarget = defaultActionTarget;

        this.set({
            title: title.unpack(),
            body: body?.unpack(),
            gicon: gicon
                ? Gio.icon_deserialize(gicon) : null,
            datetime: time
                ? GLib.DateTime.new_from_unix_local(time.unpack()) : null,
        });
    }

    _activateAction(namespacedActionId, target) {
        if (namespacedActionId) {
            if (namespacedActionId.startsWith('app.')) {
                let actionId = namespacedActionId.slice('app.'.length);
                this.source.activateAction(actionId, target);
            }
        } else {
            this.source.open();
        }
    }

    _onButtonClicked(button) {
        let {action, target} = button;
        this._activateAction(action.unpack(), target);
    }

    activate() {
        this._activateAction(this._defaultAction, this._defaultActionTarget);
        super.activate();
    }

    serialize() {
        return this._serialized;
    }
});

function InvalidAppError() {}

export const GtkNotificationDaemonAppSource = GObject.registerClass(
class GtkNotificationDaemonAppSource extends MessageTray.Source {
    constructor(appId) {
        if (!Gio.Application.id_is_valid(appId))
            throw new InvalidAppError();

        const app = Shell.AppSystem.get_default().lookup_app(`${appId}.desktop`);
        if (!app)
            throw new InvalidAppError();

        super({
            title: app.get_name(),
            icon: app.get_icon(),
            policy: new MessageTray.NotificationApplicationPolicy(appId),
        });

        this._appId = appId;
        this._app = app;

        this._notifications = {};
        this._notificationPending = false;
    }

    activateAction(actionId, target) {
        const params = target ? GLib.Variant.new('av', [target]) : null;
        this._app.activate_action(actionId, params, 0, -1, null).catch(error => {
            logError(error, `Failed to activate action for ${this._appId}`);
        });

        Main.overview.hide();
        Main.panel.closeCalendar();
    }

    open() {
        this._app.activate();
        Main.overview.hide();
        Main.panel.closeCalendar();
    }

    addNotification(notification) {
        this._notificationPending = true;

        this._notifications[notification.id]?.destroy(
            MessageTray.NotificationDestroyedReason.REPLACED);

        notification.connect('destroy', () => {
            delete this._notifications[notification.id];
        });
        this._notifications[notification.id] = notification;

        super.addNotification(notification);

        this._notificationPending = false;
    }

    destroy(reason) {
        if (this._notificationPending)
            return;
        super.destroy(reason);
    }

    removeNotification(notificationId) {
        if (this._notifications[notificationId])
            this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
    }

    serialize() {
        let notifications = [];
        for (let notificationId in this._notifications) {
            let notification = this._notifications[notificationId];
            notifications.push([notificationId, notification.serialize()]);
        }
        return [this._appId, notifications];
    }
});

const GtkNotificationsIface = loadInterfaceXML('org.gtk.Notifications');

class GtkNotificationDaemon {
    constructor() {
        this._sources = {};

        this._loadNotifications();

        this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GtkNotificationsIface, this);
        this._dbusImpl.export(Gio.DBus.session, '/org/gtk/Notifications');

        Gio.DBus.session.own_name('org.gtk.Notifications', Gio.BusNameOwnerFlags.REPLACE, null, null);
    }

    _ensureAppSource(appId) {
        if (this._sources[appId])
            return this._sources[appId];

        let source = new GtkNotificationDaemonAppSource(appId);

        source.connect('destroy', () => {
            delete this._sources[appId];
            this._saveNotifications();
        });
        source.connect('notify::count', this._saveNotifications.bind(this));
        Main.messageTray.add(source);
        this._sources[appId] = source;
        return source;
    }

    _loadNotifications() {
        this._isLoading = true;

        try {
            let value = global.get_persistent_state('a(sa(sv))', 'notifications');
            if (value) {
                let sources = value.deepUnpack();
                sources.forEach(([appId, notifications]) => {
                    if (notifications.length === 0)
                        return;

                    let source;
                    try {
                        source = this._ensureAppSource(appId);
                    } catch (e) {
                        if (e instanceof InvalidAppError)
                            return;
                        throw e;
                    }

                    notifications.forEach(([notificationId, notificationPacked]) => {
                        const notification = new GtkNotificationDaemonNotification(source,
                            notificationId,
                            notificationPacked.deepUnpack());
                        // Acknowledge all stored notification so that we don't show a banner again
                        notification.acknowledged = true;
                        source.addNotification(notification);
                    });
                });
            }
        } catch (e) {
            logError(e, 'Failed to load saved notifications');
        } finally {
            this._isLoading = false;
        }
    }

    _saveNotifications() {
        if (this._isLoading)
            return;

        let sources = [];
        for (let appId in this._sources) {
            let source = this._sources[appId];
            sources.push(source.serialize());
        }

        global.set_persistent_state('notifications', new GLib.Variant('a(sa(sv))', sources));
    }

    AddNotificationAsync(params, invocation) {
        let [appId, notificationId, notificationSerialized] = params;

        let source;
        try {
            source = this._ensureAppSource(appId);
        } catch (e) {
            if (e instanceof InvalidAppError) {
                invocation.return_error_literal(NotificationErrors,
                    NotificationError.INVALID_APP,
                    `The app by ID "${appId}" could not be found`);
                return;
            }
            throw e;
        }

        let timestamp = GLib.DateTime.new_now_local().to_unix();
        notificationSerialized['timestamp'] = new GLib.Variant('x', timestamp);

        const notification = new GtkNotificationDaemonNotification(source,
            notificationId,
            notificationSerialized);
        source.addNotification(notification);

        invocation.return_value(null);
    }

    RemoveNotificationAsync(params, invocation) {
        let [appId, notificationId] = params;
        let source = this._sources[appId];
        if (source)
            source.removeNotification(notificationId);

        invocation.return_value(null);
    }
}

export class NotificationDaemon {
    constructor() {
        this._fdoNotificationDaemon = new FdoNotificationDaemon();
        this._gtkNotificationDaemon = new GtkNotificationDaemon();
    }
}
