// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later

import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';

import * as Components from '../components/index.js';
import Config from '../../config.js';
import Plugin from '../plugin.js';
import ReplyDialog from '../ui/notification.js';


export const Metadata = {
    label: _('Notifications'),
    description: _('Share notifications with the paired device'),
    id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification',
    incomingCapabilities: [
        'kdeconnect.notification',
        'kdeconnect.notification.request',
    ],
    outgoingCapabilities: [
        'kdeconnect.notification',
        'kdeconnect.notification.action',
        'kdeconnect.notification.reply',
        'kdeconnect.notification.request',
    ],
    actions: {
        withdrawNotification: {
            label: _('Cancel Notification'),
            icon_name: 'preferences-system-notifications-symbolic',

            parameter_type: new GLib.VariantType('s'),
            incoming: [],
            outgoing: ['kdeconnect.notification'],
        },
        closeNotification: {
            label: _('Close Notification'),
            icon_name: 'preferences-system-notifications-symbolic',

            parameter_type: new GLib.VariantType('s'),
            incoming: [],
            outgoing: ['kdeconnect.notification.request'],
        },
        replyNotification: {
            label: _('Reply Notification'),
            icon_name: 'preferences-system-notifications-symbolic',

            parameter_type: new GLib.VariantType('(ssa{ss})'),
            incoming: ['kdeconnect.notification'],
            outgoing: ['kdeconnect.notification.reply'],
        },
        sendNotification: {
            label: _('Send Notification'),
            icon_name: 'preferences-system-notifications-symbolic',

            parameter_type: new GLib.VariantType('a{sv}'),
            incoming: [],
            outgoing: ['kdeconnect.notification'],
        },
        activateNotification: {
            label: _('Activate Notification'),
            icon_name: 'preferences-system-notifications-symbolic',

            parameter_type: new GLib.VariantType('(ss)'),
            incoming: [],
            outgoing: ['kdeconnect.notification.action'],
        },
    },
};


// A regex for our custom notificaiton ids
const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/;

// A list of known SMS apps
const SMS_APPS = [
    // Popular apps that don't contain the string 'sms'
    'com.android.messaging',                    // AOSP
    'com.google.android.apps.messaging',        // Google Messages
    'com.textra',                               // Textra
    'xyz.klinker.messenger',                    // Pulse
    'com.calea.echo',                           // Mood Messenger
    'com.moez.QKSMS',                           // QKSMS
    'rpkandrodev.yaata',                        // YAATA
    'com.tencent.mm',                           // WeChat
    'com.viber.voip',                           // Viber
    'com.kakao.talk',                           // KakaoTalk
    'com.concentriclivers.mms.com.android.mms', // AOSP Clone
    'fr.slvn.mms',                              // AOSP Clone
    'com.promessage.message',                   //
    'com.htc.sense.mms',                        // HTC Messages

    // Known not to work with sms plugin
    'org.thoughtcrime.securesms',               // Signal Private Messenger
    'com.samsung.android.messaging',             // Samsung Messages
];


/**
 * Try to determine if an notification is from an SMS app
 *
 * @param {Core.Packet} packet - A `kdeconnect.notification`
 * @return {boolean} Whether the notification is from an SMS app
 */
function _isSmsNotification(packet) {
    const id = packet.body.id;

    if (id.includes('sms'))
        return true;

    for (let i = 0, len = SMS_APPS.length; i < len; i++) {
        if (id.includes(SMS_APPS[i]))
            return true;
    }

    return false;
}


/**
 * Remove a local libnotify or Gtk notification.
 *
 * @param {String|Number} id - Gtk (string) or libnotify id (uint32)
 * @param {String|null} application - Application Id if Gtk or null
 */
function _removeNotification(id, application = null) {
    let name, path, method, variant;

    if (application !== null) {
        name = 'org.gtk.Notifications';
        method = 'RemoveNotification';
        path = '/org/gtk/Notifications';
        variant = new GLib.Variant('(ss)', [application, id]);
    } else {
        name = 'org.freedesktop.Notifications';
        path = '/org/freedesktop/Notifications';
        method = 'CloseNotification';
        variant = new GLib.Variant('(u)', [id]);
    }

    Gio.DBus.session.call(
        name, path, name, method, variant, null,
        Gio.DBusCallFlags.NONE, -1, null,
        (connection, res) => {
            try {
                connection.call_finish(res);
            } catch (e) {
                logError(e);
            }
        }
    );
}


/**
 * Notification Plugin
 * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
 * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
 */
const NotificationPlugin = GObject.registerClass({
    GTypeName: 'GSConnectNotificationPlugin',
}, class NotificationPlugin extends Plugin {

    _init(device) {
        super._init(device, 'notification');

        this._listener = Components.acquire('notification');
        this._session = Components.acquire('session');

        this._notificationAddedId = this._listener.connect(
            'notification-added',
            this._onNotificationAdded.bind(this)
        );

        // Load application notification settings
        this._applicationsChangedId = this.settings.connect(
            'changed::applications',
            this._onApplicationsChanged.bind(this)
        );
        this._onApplicationsChanged(this.settings, 'applications');
        this._applicationsChangedSkip = false;
    }

    connected() {
        super.connected();

        this._requestNotifications();
    }

    handlePacket(packet) {
        switch (packet.type) {
            case 'kdeconnect.notification':
                this._handleNotification(packet);
                break;

            // TODO
            case 'kdeconnect.notification.action':
                this._handleNotificationAction(packet);
                break;

            // No Linux/BSD desktop notifications are repliable as yet
            case 'kdeconnect.notification.reply':
                debug(`Not implemented: ${packet.type}`);
                break;

            case 'kdeconnect.notification.request':
                this._handleNotificationRequest(packet);
                break;

            default:
                debug(`Unknown notification packet: ${packet.type}`);
        }
    }

    _onApplicationsChanged(settings, key) {
        if (this._applicationsChangedSkip)
            return;

        try {
            const json = settings.get_string(key);
            this._applications = JSON.parse(json);
        } catch (e) {
            debug(e, this.device.name);

            this._applicationsChangedSkip = true;
            settings.set_string(key, '{}');
            this._applicationsChangedSkip = false;
        }
    }

    _onNotificationAdded(listener, notification) {
        try {
            const notif = notification.full_unpack();

            // An unconfigured application
            if (notif.appName && !this._applications[notif.appName]) {
                this._applications[notif.appName] = {
                    iconName: 'system-run-symbolic',
                    enabled: true,
                };

                // Store the themed icons for the device preferences window
                if (notif.icon === undefined) {
                    // Keep default

                } else if (typeof notif.icon === 'string') {
                    this._applications[notif.appName].iconName = notif.icon;

                } else if (notif.icon instanceof Gio.ThemedIcon) {
                    const iconName = notif.icon.get_names()[0];
                    this._applications[notif.appName].iconName = iconName;
                }

                this._applicationsChangedSkip = true;
                this.settings.set_string(
                    'applications',
                    JSON.stringify(this._applications)
                );
                this._applicationsChangedSkip = false;
            }

            // Sending notifications forbidden
            if (!this.settings.get_boolean('send-notifications'))
                return;

            // Sending when the session is active is forbidden
            if (!this.settings.get_boolean('send-active') && this._session.active)
                return;

            // Notifications disabled for this application
            if (notif.appName && !this._applications[notif.appName].enabled)
                return;

            this.sendNotification(notif);
        } catch (e) {
            debug(e, this.device.name);
        }
    }

    /**
     * Handle an incoming notification or closed report.
     *
     * FIXME: upstream kdeconnect-android is tagging many notifications as
     *        `silent`, causing them to never be shown. Since we already handle
     *        duplicates in the Shell, we ignore that flag for now.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification`
     */
    _handleNotification(packet) {
        // A report that a remote notification has been dismissed
        if (packet.body.hasOwnProperty('isCancel'))
            this.device.hideNotification(packet.body.id);

        // A normal, remote notification
        else
            this._receiveNotification(packet);
    }

    /**
     * Handle an incoming request to activate a notification action.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification.action`
     */
    _handleNotificationAction(packet) {
        throw new GObject.NotImplementedError();
    }

    /**
     * Handle an incoming request to close or list notifications.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification.request`
     */
    _handleNotificationRequest(packet) {
        // A request for our notifications. This isn't implemented and would be
        // pretty hard to without communicating with GNOME Shell.
        if (packet.body.hasOwnProperty('request'))
            return;

        // A request to close a local notification
        //
        // TODO: kdeconnect-android doesn't send these, and will instead send a
        // kdeconnect.notification packet with isCancel and an id of "0".
        //
        // For clients that do support it, we report notification ids in the
        // form "type|application-id|notification-id" so we can close it with
        // the appropriate service.
        if (packet.body.hasOwnProperty('cancel')) {
            const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);

            if (type === 'fdo')
                _removeNotification(parseInt(id));
            else if (type === 'gtk')
                _removeNotification(id, application);
        }
    }

    /**
     * Upload an icon from a GLib.Bytes object.
     *
     * @param {Core.Packet} packet - The packet for the notification
     * @param {GLib.Bytes} bytes - The icon bytes
     */
    _uploadBytesIcon(packet, bytes) {
        const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
        this._uploadIconStream(packet, stream, bytes.get_size());
    }

    /**
     * Upload an icon from a Gio.File object.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification`
     * @param {Gio.File} file - A file object for the icon
     */
    async _uploadFileIcon(packet, file) {
        const read = file.read_async(GLib.PRIORITY_DEFAULT, null);
        const query = file.query_info_async('standard::size',
            Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null);
        const [stream, info] = await Promise.all([read, query]);

        this._uploadIconStream(packet, stream, info.get_size());
    }

    /**
     * A function for uploading GThemedIcons
     *
     * @param {Core.Packet} packet - The packet for the notification
     * @param {Gio.ThemedIcon} icon - The GIcon to upload
     */
    _uploadThemedIcon(packet, icon) {
        const theme = Gtk.IconTheme.get_default();
        let file = null;

        for (const name of icon.names) {
            // NOTE: kdeconnect-android doesn't support SVGs
            const size = Math.max.apply(null, theme.get_icon_sizes(name));
            const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);

            // Send the first icon we find from the options
            if (info) {
                file = Gio.File.new_for_path(info.get_filename());
                break;
            }
        }

        if (file)
            this._uploadFileIcon(packet, file);
        else
            this.device.sendPacket(packet);
    }

    /**
     * All icon types end up being uploaded in this function.
     *
     * @param {Core.Packet} packet - The packet for the notification
     * @param {Gio.InputStream} stream - A stream to read the icon bytes from
     * @param {number} size - Size of the icon in bytes
     */
    async _uploadIconStream(packet, stream, size) {
        try {
            const transfer = this.device.createTransfer();
            transfer.addStream(packet, stream, size);

            await transfer.start();
        } catch (e) {
            debug(e);

            this.device.sendPacket(packet);
        }
    }

    /**
     * Upload an icon from a GIcon or themed icon name.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification`
     * @param {Gio.Icon|string|null} icon - An icon or %null
     * @return {Promise} A promise for the operation
     */
    _uploadIcon(packet, icon = null) {
        // Normalize strings into GIcons
        if (typeof icon === 'string')
            icon = Gio.Icon.new_for_string(icon);

        if (icon instanceof Gio.ThemedIcon)
            return this._uploadThemedIcon(packet, icon);

        if (icon instanceof Gio.FileIcon)
            return this._uploadFileIcon(packet, icon.get_file());

        if (icon instanceof Gio.BytesIcon)
            return this._uploadBytesIcon(packet, icon.get_bytes());

        return this.device.sendPacket(packet);
    }

    /**
     * Send a local notification to the remote device.
     *
     * @param {Object} notif - A dictionary of notification parameters
     * @param {string} notif.appName - The notifying application
     * @param {string} notif.id - The notification ID
     * @param {string} notif.title - The notification title
     * @param {string} notif.body - The notification body
     * @param {string} notif.ticker - The notification title & body
     * @param {boolean} notif.isClearable - If the notification can be closed
     * @param {string|Gio.Icon} notif.icon - An icon name or GIcon
     */
    async sendNotification(notif) {
        try {
            const icon = notif.icon || null;
            delete notif.icon;

            await this._uploadIcon({
                type: 'kdeconnect.notification',
                body: notif,
            }, icon);
        } catch (e) {
            logError(e);
        }
    }

    async _downloadIcon(packet) {
        try {
            if (!packet.hasPayload())
                return null;

            // Save the file in the global cache
            const path = GLib.build_filenamev([
                Config.CACHEDIR,
                packet.body.payloadHash || `${Date.now()}`,
            ]);

            // Check if we've already downloaded this icon
            // NOTE: if we reject the transfer kdeconnect-android will resend
            //       the notification packet, which may cause problems wrt #789
            const file = Gio.File.new_for_path(path);

            if (file.query_exists(null))
                return new Gio.FileIcon({file: file});

            // Open the target path and create a transfer
            const transfer = this.device.createTransfer();

            transfer.addFile(packet, file);

            try {
                await transfer.start();

                return new Gio.FileIcon({file: file});
            } catch (e) {
                debug(e, this.device.name);

                file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
                return null;
            }
        } catch (e) {
            debug(e, this.device.name);
            return null;
        }
    }

    /**
     * Receive an incoming notification.
     *
     * @param {Core.Packet} packet - A `kdeconnect.notification`
     */
    async _receiveNotification(packet) {
        try {
            // Set defaults
            let action = null;
            let buttons = [];
            let id = packet.body.id;
            let title = packet.body.appName;
            let body = `${packet.body.title}: ${packet.body.text}`;
            let icon = await this._downloadIcon(packet);

            // Repliable Notification
            if (packet.body.requestReplyId) {
                id = `${packet.body.id}|${packet.body.requestReplyId}`;
                action = {
                    name: 'replyNotification',
                    parameter: new GLib.Variant('(ssa{ss})', [
                        packet.body.requestReplyId,
                        '',
                        {
                            appName: packet.body.appName,
                            title: packet.body.title,
                            text: packet.body.text,
                        },
                    ]),
                };
            }

            // Notification Actions
            if (packet.body.actions) {
                buttons = packet.body.actions.map(action => {
                    return {
                        label: action,
                        action: 'activateNotification',
                        parameter: new GLib.Variant('(ss)', [id, action]),
                    };
                });
            }

            // Special case for Missed Calls
            if (packet.body.id.includes('MissedCall')) {
                title = packet.body.title;
                body = packet.body.text;

                if (icon === null)
                    icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});

            // Special case for SMS notifications
            } else if (_isSmsNotification(packet)) {
                title = packet.body.title;
                body = packet.body.text;
                action = {
                    name: 'replySms',
                    parameter: new GLib.Variant('s', packet.body.title),
                };

                if (icon === null)
                    icon = new Gio.ThemedIcon({name: 'sms-symbolic'});

            // Special case where 'appName' is the same as 'title'
            } else if (packet.body.appName === packet.body.title) {
                body = packet.body.text;
            }

            // Use the device icon if we still don't have one
            if (icon === null)
                icon = new Gio.ThemedIcon({name: this.device.icon_name});

            // Show the notification
            this.device.showNotification({
                id: id,
                title: title,
                body: body,
                icon: icon,
                action: action,
                buttons: buttons,
            });
        } catch (e) {
            logError(e);
        }
    }

    /**
     * Request the remote notifications be sent
     */
    _requestNotifications() {
        this.device.sendPacket({
            type: 'kdeconnect.notification.request',
            body: {request: true},
        });
    }

    /**
     * Report that a local notification has been closed/dismissed.
     * TODO: kdeconnect-android doesn't handle incoming isCancel packets.
     *
     * @param {string} id - The local notification id
     */
    withdrawNotification(id) {
        this.device.sendPacket({
            type: 'kdeconnect.notification',
            body: {
                isCancel: true,
                id: id,
            },
        });
    }

    /**
     * Close a remote notification.
     * TODO: ignore local notifications
     *
     * @param {string} id - The remote notification id
     */
    closeNotification(id) {
        this.device.sendPacket({
            type: 'kdeconnect.notification.request',
            body: {cancel: id},
        });
    }

    /**
     * Reply to a notification sent with a requestReplyId UUID
     *
     * @param {string} uuid - The requestReplyId for the repliable notification
     * @param {string} message - The message to reply with
     * @param {Object} notification - The original notification packet
     */
    replyNotification(uuid, message, notification) {
        // If this happens for some reason, things will explode
        if (!uuid)
            throw Error('Missing UUID');

        // If the message has no content, open a dialog for the user to add one
        if (!message) {
            const dialog = new ReplyDialog({
                device: this.device,
                uuid: uuid,
                notification: notification,
                plugin: this,
            });
            dialog.present();

        // Otherwise just send the reply
        } else {
            this.device.sendPacket({
                type: 'kdeconnect.notification.reply',
                body: {
                    requestReplyId: uuid,
                    message: message,
                },
            });
        }
    }

    /**
     * Activate a remote notification action
     *
     * @param {string} id - The remote notification id
     * @param {string} action - The notification action (label)
     */
    activateNotification(id, action) {
        this.device.sendPacket({
            type: 'kdeconnect.notification.action',
            body: {
                action: action,
                key: id,
            },
        });
    }

    destroy() {
        this.settings.disconnect(this._applicationsChangedId);

        if (this._listener !== undefined) {
            this._listener.disconnect(this._notificationAddedId);
            this._listener = Components.release('notification');
        }

        if (this._session !== undefined)
            this._session = Components.release('session');

        super.destroy();
    }
});

export default NotificationPlugin;
