// 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 Pango from 'gi://Pango';

import Config from '../config.js';
import plugins from '../service/plugins/index.js';
import * as Keybindings from './keybindings.js';


// Build a list of plugins and shortcuts for devices
const DEVICE_PLUGINS = [];
const DEVICE_SHORTCUTS = {};

for (const name in plugins) {
    const module = plugins[name];

    if (module.Metadata === undefined)
        continue;

    // Plugins
    DEVICE_PLUGINS.push(name);

    // Shortcuts (GActions without parameters)
    for (const [name, action] of Object.entries(module.Metadata.actions)) {
        if (action.parameter_type === null)
            DEVICE_SHORTCUTS[name] = [action.icon_name, action.label];
    }
}


/**
 * A Gtk.ListBoxHeaderFunc for sections that adds separators between each row.
 *
 * @param {Gtk.ListBoxRow} row - The current row
 * @param {Gtk.ListBoxRow} before - The previous row
 */
export function rowSeparators(row, before) {
    const header = row.get_header();

    if (before === null) {
        if (header !== null)
            header.destroy();

        return;
    }

    if (header === null)
        row.set_header(new Gtk.Separator({visible: true}));
}


/**
 * A Gtk.ListBoxSortFunc for SectionRow rows
 *
 * @param {Gtk.ListBoxRow} row1 - The first row
 * @param {Gtk.ListBoxRow} row2 - The second row
 * @return {number} -1, 0 or 1
 */
export function titleSortFunc(row1, row2) {
    if (!row1.title || !row2.title)
        return 0;

    return row1.title.localeCompare(row2.title);
}


/**
 * A row for a section of settings
 */
const SectionRow = GObject.registerClass({
    GTypeName: 'GSConnectPreferencesSectionRow',
    Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-section-row.ui',
    Children: ['icon-image', 'title-label', 'subtitle-label'],
    Properties: {
        'gicon': GObject.ParamSpec.object(
            'gicon',
            'GIcon',
            'A GIcon for the row',
            GObject.ParamFlags.READWRITE,
            Gio.Icon.$gtype
        ),
        'icon-name': GObject.ParamSpec.string(
            'icon-name',
            'Icon Name',
            'An icon name for the row',
            GObject.ParamFlags.READWRITE,
            null
        ),
        'subtitle': GObject.ParamSpec.string(
            'subtitle',
            'Subtitle',
            'A subtitle for the row',
            GObject.ParamFlags.READWRITE,
            null
        ),
        'title': GObject.ParamSpec.string(
            'title',
            'Title',
            'A title for the row',
            GObject.ParamFlags.READWRITE,
            null
        ),
        'widget': GObject.ParamSpec.object(
            'widget',
            'Widget',
            'An action widget for the row',
            GObject.ParamFlags.READWRITE,
            Gtk.Widget.$gtype
        ),
    },
}, class SectionRow extends Gtk.ListBoxRow {

    _init(params = {}) {
        super._init();

        // NOTE: we can't pass construct properties to _init() because the
        //       template children are not assigned until after it runs.
        this.freeze_notify();
        Object.assign(this, params);
        this.thaw_notify();
    }

    get icon_name() {
        return this.icon_image.icon_name;
    }

    set icon_name(icon_name) {
        if (this.icon_name === icon_name)
            return;

        this.icon_image.visible = !!icon_name;
        this.icon_image.icon_name = icon_name;
        this.notify('icon-name');
    }

    get gicon() {
        return this.icon_image.gicon;
    }

    set gicon(gicon) {
        if (this.gicon === gicon)
            return;

        this.icon_image.visible = !!gicon;
        this.icon_image.gicon = gicon;
        this.notify('gicon');
    }

    get title() {
        return this.title_label.label;
    }

    set title(text) {
        if (this.title === text)
            return;

        this.title_label.visible = !!text;
        this.title_label.label = text;
        this.notify('title');
    }

    get subtitle() {
        return this.subtitle_label.label;
    }

    set subtitle(text) {
        if (this.subtitle === text)
            return;

        this.subtitle_label.visible = !!text;
        this.subtitle_label.label = text;
        this.notify('subtitle');
    }

    get widget() {
        if (this._widget === undefined)
            this._widget = null;

        return this._widget;
    }

    set widget(widget) {
        if (this.widget === widget)
            return;

        if (this.widget instanceof Gtk.Widget)
            this.widget.destroy();

        // Add the widget
        this._widget = widget;
        this.get_child().attach(widget, 2, 0, 1, 2);
        this.notify('widget');
    }
});


/**
 * Command Editor Dialog
 */
const CommandEditor = GObject.registerClass({
    GTypeName: 'GSConnectPreferencesCommandEditor',
    Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-command-editor.ui',
    Children: [
        'cancel-button', 'save-button',
        'command-entry', 'name-entry', 'command-chooser',
    ],
}, class CommandEditor extends Gtk.Dialog {

    _onBrowseCommand(entry, icon_pos, event) {
        this.command_chooser.present();
    }

    _onCommandChosen(dialog, response_id) {
        if (response_id === Gtk.ResponseType.OK)
            this.command_entry.text = dialog.get_filename();

        dialog.hide();
    }

    _onEntryChanged(entry, pspec) {
        this.save_button.sensitive = (this.command_name && this.command_line);
    }

    get command_line() {
        return this.command_entry.text;
    }

    set command_line(text) {
        this.command_entry.text = text;
    }

    get command_name() {
        return this.name_entry.text;
    }

    set command_name(text) {
        this.name_entry.text = text;
    }
});


/**
 * A widget for configuring a remote device.
 */
export const Panel = GObject.registerClass({
    GTypeName: 'GSConnectPreferencesDevicePanel',
    Properties: {
        'device': GObject.ParamSpec.object(
            'device',
            'Device',
            'The device being configured',
            GObject.ParamFlags.READWRITE,
            GObject.Object.$gtype
        ),
    },
    Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-device-panel.ui',
    Children: [
        'sidebar', 'stack', 'infobar',

        // Sharing
        'sharing', 'sharing-page',
        'desktop-list', 'clipboard', 'clipboard-sync', 'mousepad', 'mpris', 'systemvolume',
        'share', 'share-list', 'receive-files', 'receive-directory',

        // Battery
        'battery',
        'battery-device-label', 'battery-device', 'battery-device-list',
        'battery-system-label', 'battery-system', 'battery-system-list',
        'battery-custom-notification-value',

        // RunCommand
        'runcommand', 'runcommand-page',
        'command-list', 'command-add',

        // Notifications
        'notification', 'notification-page',
        'notification-list', 'notification-apps',

        // Telephony
        'telephony', 'telephony-page',
        'ringing-list', 'ringing-volume', 'talking-list', 'talking-volume',

        // Shortcuts
        'shortcuts-page',
        'shortcuts-actions', 'shortcuts-actions-title', 'shortcuts-actions-list',

        // Advanced
        'advanced-page',
        'plugin-list', 'experimental-list',

        'device-menu',
    ],
}, class Panel extends Gtk.Grid {

    _init(device) {
        super._init({
            device: device,
        });

        // GSettings
        this.settings = new Gio.Settings({
            settings_schema: Config.GSCHEMA.lookup(
                'org.gnome.Shell.Extensions.GSConnect.Device',
                true
            ),
            path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`,
        });

        // Infobar
        this.device.bind_property(
            'paired',
            this.infobar,
            'reveal-child',
            (GObject.BindingFlags.SYNC_CREATE |
             GObject.BindingFlags.INVERT_BOOLEAN)
        );

        this._setupActions();

        // Settings Pages
        this._sharingSettings();
        this._batterySettings();
        this._runcommandSettings();
        this._notificationSettings();
        this._telephonySettings();
        // --------------------------
        this._keybindingSettings();
        this._advancedSettings();

        // Separate plugins and other settings
        this.sidebar.set_header_func((row, before) => {
            if (row.get_name() === 'shortcuts')
                row.set_header(new Gtk.Separator({visible: true}));
        });
    }

    get menu() {
        if (this._menu === undefined) {
            this._menu = this.device_menu;
            this._menu.prepend_section(null, this.device.menu);
            this.insert_action_group('device', this.device.action_group);
        }

        return this._menu;
    }

    get_incoming_supported(type) {
        const incoming = this.settings.get_strv('incoming-capabilities');
        return incoming.includes(`kdeconnect.${type}`);
    }

    get_outgoing_supported(type) {
        const outgoing = this.settings.get_strv('outgoing-capabilities');
        return outgoing.includes(`kdeconnect.${type}`);
    }

    _onKeynavFailed(widget, direction) {
        if (direction === Gtk.DirectionType.UP && widget.prev)
            widget.prev.child_focus(direction);

        else if (direction === Gtk.DirectionType.DOWN && widget.next)
            widget.next.child_focus(direction);

        return true;
    }

    _onSwitcherRowSelected(box, row) {
        this.stack.set_visible_child_name(row.get_name());
    }

    _onSectionRowActivated(box, row) {
        if (row.widget !== undefined)
            row.widget.active = !row.widget.active;
    }

    _onToggleRowActivated(box, row) {
        const widget = row.get_child().get_child_at(1, 0);
        widget.active = !widget.active;
    }

    _onEncryptionInfo() {
        const dialog = new Gtk.MessageDialog({
            buttons: Gtk.ButtonsType.OK,
            text: _('Encryption Info'),
            secondary_text: this.device.encryption_info,
            modal: true,
            transient_for: this.get_toplevel(),
        });
        dialog.connect('response', (dialog) => dialog.destroy());
        dialog.present();
    }

    _deviceAction(action, parameter) {
        this.action_group.activate_action(action.name, parameter);
    }

    dispose() {
        if (this._commandEditor !== undefined)
            this._commandEditor.destroy();

        // Device signals
        this.device.action_group.disconnect(this._actionAddedId);
        this.device.action_group.disconnect(this._actionRemovedId);

        // GSettings
        for (const settings of Object.values(this._pluginSettings))
            settings.run_dispose();

        this.settings.disconnect(this._keybindingsId);
        this.settings.disconnect(this._disabledPluginsId);
        this.settings.disconnect(this._supportedPluginsId);
        this.settings.run_dispose();
    }

    pluginSettings(name) {
        if (this._pluginSettings === undefined)
            this._pluginSettings = {};

        if (!this._pluginSettings.hasOwnProperty(name)) {
            const meta = plugins[name].Metadata;

            this._pluginSettings[name] = new Gio.Settings({
                settings_schema: Config.GSCHEMA.lookup(meta.id, -1),
                path: `${this.settings.path}plugin/${name}/`,
            });
        }

        return this._pluginSettings[name];
    }

    _setupActions() {
        this.actions = new Gio.SimpleActionGroup();
        this.insert_action_group('settings', this.actions);

        let settings = this.pluginSettings('battery');
        this.actions.add_action(settings.create_action('send-statistics'));
        this.actions.add_action(settings.create_action('low-battery-notification'));
        this.actions.add_action(settings.create_action('custom-battery-notification'));
        this.actions.add_action(settings.create_action('custom-battery-notification-value'));
        this.actions.add_action(settings.create_action('full-battery-notification'));

        settings = this.pluginSettings('clipboard');
        this.actions.add_action(settings.create_action('send-content'));
        this.actions.add_action(settings.create_action('receive-content'));

        settings = this.pluginSettings('contacts');
        this.actions.add_action(settings.create_action('contacts-source'));

        settings = this.pluginSettings('mousepad');
        this.actions.add_action(settings.create_action('share-control'));

        settings = this.pluginSettings('mpris');
        this.actions.add_action(settings.create_action('share-players'));

        settings = this.pluginSettings('notification');
        this.actions.add_action(settings.create_action('send-notifications'));
        this.actions.add_action(settings.create_action('send-active'));

        settings = this.pluginSettings('sftp');
        this.actions.add_action(settings.create_action('automount'));

        settings = this.pluginSettings('share');
        this.actions.add_action(settings.create_action('receive-files'));

        settings = this.pluginSettings('sms');
        this.actions.add_action(settings.create_action('legacy-sms'));

        settings = this.pluginSettings('systemvolume');
        this.actions.add_action(settings.create_action('share-sinks'));

        settings = this.pluginSettings('telephony');
        this.actions.add_action(settings.create_action('ringing-volume'));
        this.actions.add_action(settings.create_action('ringing-pause'));
        this.actions.add_action(settings.create_action('talking-volume'));
        this.actions.add_action(settings.create_action('talking-pause'));
        this.actions.add_action(settings.create_action('talking-microphone'));

        // Pair Actions
        const encryption_info = new Gio.SimpleAction({name: 'encryption-info'});
        encryption_info.connect('activate', this._onEncryptionInfo.bind(this));
        this.actions.add_action(encryption_info);

        const status_pair = new Gio.SimpleAction({name: 'pair'});
        status_pair.connect('activate', this._deviceAction.bind(this.device));
        this.settings.bind('paired', status_pair, 'enabled', 16);
        this.actions.add_action(status_pair);

        const status_unpair = new Gio.SimpleAction({name: 'unpair'});
        status_unpair.connect('activate', this._deviceAction.bind(this.device));
        this.settings.bind('paired', status_unpair, 'enabled', 0);
        this.actions.add_action(status_unpair);
    }

    /**
     * Sharing Settings
     */
    _sharingSettings() {
        // Share Plugin
        const settings = this.pluginSettings('share');

        settings.connect(
            'changed::receive-directory',
            this._onReceiveDirectoryChanged.bind(this)
        );
        this._onReceiveDirectoryChanged(settings, 'receive-directory');

        // Visibility
        this.desktop_list.foreach(row => {
            const name = row.get_name();
            row.visible = this.get_outgoing_supported(`${name}.request`);
        });

        // Separators & Sorting
        this.desktop_list.set_header_func(rowSeparators);

        this.desktop_list.set_sort_func((row1, row2) => {
            row1 = row1.get_child().get_child_at(0, 0);
            row2 = row2.get_child().get_child_at(0, 0);
            return row1.label.localeCompare(row2.label);
        });
        this.share_list.set_header_func(rowSeparators);

        // Scroll with keyboard focus
        const sharing_box = this.sharing_page.get_child().get_child();
        sharing_box.set_focus_vadjustment(this.sharing_page.vadjustment);

        // Continue focus chain between lists
        this.desktop_list.next = this.share_list;
        this.share_list.prev = this.desktop_list;
    }

    _onReceiveDirectoryChanged(settings, key) {
        let receiveDir = settings.get_string(key);

        if (receiveDir.length === 0) {
            receiveDir = GLib.get_user_special_dir(
                GLib.UserDirectory.DIRECTORY_DOWNLOAD
            );

            // Account for some corner cases with a fallback
            const homeDir = GLib.get_home_dir();

            if (!receiveDir || receiveDir === homeDir)
                receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);

            settings.set_string(key, receiveDir);
        }

        if (this.receive_directory.get_filename() !== receiveDir)
            this.receive_directory.set_filename(receiveDir);
    }

    _onReceiveDirectorySet(button) {
        const settings = this.pluginSettings('share');
        const receiveDir = settings.get_string('receive-directory');
        const filename = button.get_filename();

        if (filename !== receiveDir)
            settings.set_string('receive-directory', filename);
    }

    /**
     * Battery Settings
     */
    async _batterySettings() {
        try {
            this.battery_device_list.set_header_func(rowSeparators);
            this.battery_system_list.set_header_func(rowSeparators);
            const settings = this.pluginSettings('battery');
            const oldLevel = settings.get_uint('custom-battery-notification-value');
            this.battery_custom_notification_value.set_value(oldLevel);

            // If the device can't handle statistics we're done
            if (!this.get_incoming_supported('battery')) {
                this.battery_system_label.visible = false;
                this.battery_system.visible = false;
                return;
            }

            // Check UPower for a battery
            const hasBattery = await new Promise((resolve, reject) => {
                Gio.DBus.system.call(
                    'org.freedesktop.UPower',
                    '/org/freedesktop/UPower/devices/DisplayDevice',
                    'org.freedesktop.DBus.Properties',
                    'Get',
                    new GLib.Variant('(ss)', [
                        'org.freedesktop.UPower.Device',
                        'IsPresent',
                    ]),
                    null,
                    Gio.DBusCallFlags.NONE,
                    -1,
                    null,
                    (connection, res) => {
                        try {
                            const variant = connection.call_finish(res);
                            const value = variant.deepUnpack()[0];
                            const isPresent = value.get_boolean();

                            resolve(isPresent);
                        } catch (e) {
                            resolve(false);
                        }
                    }
                );
            });

            this.battery_system_label.visible = hasBattery;
            this.battery_system.visible = hasBattery;
        } catch (e) {
            this.battery_system_label.visible = false;
            this.battery_system.visible = false;
        }
    }

    _setCustomChargeLevel(spin) {
        const settings = this.pluginSettings('battery');
        settings.set_uint('custom-battery-notification-value', spin.get_value_as_int());
    }

    /**
     * RunCommand Page
     */
    _runcommandSettings() {
        // Scroll with keyboard focus
        const runcommand_box = this.runcommand_page.get_child().get_child();
        runcommand_box.set_focus_vadjustment(this.runcommand_page.vadjustment);

        // Local Command List
        const settings = this.pluginSettings('runcommand');
        this._commands = settings.get_value('command-list').recursiveUnpack();

        this.command_list.set_sort_func(this._sortCommands);
        this.command_list.set_header_func(rowSeparators);

        for (const uuid of Object.keys(this._commands))
            this._insertCommand(uuid);
    }

    _sortCommands(row1, row2) {
        if (!row1.title || !row2.title)
            return 1;

        return row1.title.localeCompare(row2.title);
    }

    _insertCommand(uuid) {
        const row = new SectionRow({
            title: this._commands[uuid].name,
            subtitle: this._commands[uuid].command,
            activatable: false,
        });
        row.set_name(uuid);
        row.subtitle_label.ellipsize = Pango.EllipsizeMode.MIDDLE;

        const editButton = new Gtk.Button({
            image: new Gtk.Image({
                icon_name: 'document-edit-symbolic',
                pixel_size: 16,
                visible: true,
            }),
            tooltip_text: _('Edit'),
            valign: Gtk.Align.CENTER,
            vexpand: true,
            visible: true,
        });
        editButton.connect('clicked', this._onEditCommand.bind(this));
        editButton.get_accessible().set_name(_('Edit'));
        row.get_child().attach(editButton, 2, 0, 1, 2);

        const deleteButton = new Gtk.Button({
            image: new Gtk.Image({
                icon_name: 'edit-delete-symbolic',
                pixel_size: 16,
                visible: true,
            }),
            tooltip_text: _('Remove'),
            valign: Gtk.Align.CENTER,
            vexpand: true,
            visible: true,
        });
        deleteButton.connect('clicked', this._onDeleteCommand.bind(this));
        deleteButton.get_accessible().set_name(_('Remove'));
        row.get_child().attach(deleteButton, 3, 0, 1, 2);

        this.command_list.add(row);
    }

    _onEditCommand(widget) {
        if (this._commandEditor === undefined) {
            this._commandEditor = new CommandEditor({
                modal: true,
                transient_for: this.get_toplevel(),
                use_header_bar: true,
            });

            this._commandEditor.connect(
                'response',
                this._onSaveCommand.bind(this)
            );

            this._commandEditor.resize(1, 1);
        }

        if (widget instanceof Gtk.Button) {
            const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
            const uuid = row.get_name();

            this._commandEditor.uuid = uuid;
            this._commandEditor.command_name = this._commands[uuid].name;
            this._commandEditor.command_line = this._commands[uuid].command;
        } else {
            this._commandEditor.uuid = GLib.uuid_string_random();
            this._commandEditor.command_name = '';
            this._commandEditor.command_line = '';
        }

        this._commandEditor.present();
    }

    _storeCommands() {
        const variant = {};

        for (const [uuid, command] of Object.entries(this._commands))
            variant[uuid] = new GLib.Variant('a{ss}', command);

        this.pluginSettings('runcommand').set_value(
            'command-list',
            new GLib.Variant('a{sv}', variant)
        );
    }

    _onDeleteCommand(button) {
        const row = button.get_ancestor(Gtk.ListBoxRow.$gtype);
        delete this._commands[row.get_name()];
        row.destroy();

        this._storeCommands();
    }

    _onSaveCommand(dialog, response_id) {
        if (response_id === Gtk.ResponseType.ACCEPT) {
            this._commands[dialog.uuid] = {
                name: dialog.command_name,
                command: dialog.command_line,
            };

            this._storeCommands();

            //
            let row = null;

            for (const child of this.command_list.get_children()) {
                if (child.get_name() === dialog.uuid) {
                    row = child;
                    break;
                }
            }

            if (row === null) {
                this._insertCommand(dialog.uuid);
            } else {
                row.set_name(dialog.uuid);
                row.title = dialog.command_name;
                row.subtitle = dialog.command_line;
            }
        }

        dialog.hide();
    }

    /**
     * Notification Settings
     */
    _notificationSettings() {
        const settings = this.pluginSettings('notification');

        settings.bind(
            'send-notifications',
            this.notification_apps,
            'sensitive',
            Gio.SettingsBindFlags.DEFAULT
        );

        // Separators & Sorting
        this.notification_list.set_header_func(rowSeparators);

        // Scroll with keyboard focus
        const notification_box = this.notification_page.get_child().get_child();
        notification_box.set_focus_vadjustment(this.notification_page.vadjustment);

        // Continue focus chain between lists
        this.notification_list.next = this.notification_apps;
        this.notification_apps.prev = this.notification_list;

        this.notification_apps.set_sort_func(titleSortFunc);
        this.notification_apps.set_header_func(rowSeparators);

        this._populateApplications(settings);
    }

    _toggleNotification(widget) {
        try {
            const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
            const settings = this.pluginSettings('notification');
            let applications = {};

            try {
                applications = JSON.parse(settings.get_string('applications'));
            } catch (e) {
                applications = {};
            }

            applications[row.title].enabled = !applications[row.title].enabled;
            row.widget.state = applications[row.title].enabled;
            settings.set_string('applications', JSON.stringify(applications));

        } catch (e) {
            logError(e);
        }
    }



    _populateApplications(settings) {
        const applications = this._queryApplications(settings);

        for (const name in applications) {
            const row = new SectionRow({
                gicon: Gio.Icon.new_for_string(applications[name].iconName),
                title: name,
                height_request: 48,
                widget: new Gtk.Switch({
                    state: applications[name].enabled,
                    margin_start: 12,
                    margin_end: 12,
                    halign: Gtk.Align.END,
                    valign: Gtk.Align.CENTER,
                    vexpand: true,
                    visible: true,
                }),
            });

            row.widget.connect('notify::active', this._toggleNotification.bind(this));
            this.notification_apps.add(row);
        }
    }

    _queryApplications(settings) {
        let applications = {};

        try {
            applications = JSON.parse(settings.get_string('applications'));
        } catch (e) {
            applications = {};
        }

        // Scan applications that statically declare to show notifications
        const ignoreId = 'org.gnome.Shell.Extensions.GSConnect.desktop';

        for (const appInfo of Gio.AppInfo.get_all()) {
            if (appInfo.get_id() === ignoreId)
                continue;

            if (!appInfo.get_boolean('X-GNOME-UsesNotifications'))
                continue;

            const appName = appInfo.get_name();

            if (appName === null || applications.hasOwnProperty(appName))
                continue;

            let icon = appInfo.get_icon();
            icon = (icon) ? icon.to_string() : 'application-x-executable';

            applications[appName] = {
                iconName: icon,
                enabled: true,
            };
        }

        settings.set_string('applications', JSON.stringify(applications));

        return applications;
    }

    /**
     * Telephony Settings
     */
    _telephonySettings() {
        // Continue focus chain between lists
        this.ringing_list.next = this.talking_list;
        this.talking_list.prev = this.ringing_list;

        this.ringing_list.set_header_func(rowSeparators);
        this.talking_list.set_header_func(rowSeparators);
    }

    /**
     * Keyboard Shortcuts
     */
    _keybindingSettings() {
        // Scroll with keyboard focus
        const shortcuts_box = this.shortcuts_page.get_child().get_child();
        shortcuts_box.set_focus_vadjustment(this.shortcuts_page.vadjustment);

        // Filter & Sort
        this.shortcuts_actions_list.set_filter_func(this._filterPluginKeybindings.bind(this));
        this.shortcuts_actions_list.set_header_func(rowSeparators);
        this.shortcuts_actions_list.set_sort_func(titleSortFunc);

        // Init
        for (const name in DEVICE_SHORTCUTS)
            this._addPluginKeybinding(name);

        this._setPluginKeybindings();

        // Watch for GAction and Keybinding changes
        this._actionAddedId = this.device.action_group.connect(
            'action-added',
            () => this.shortcuts_actions_list.invalidate_filter()
        );
        this._actionRemovedId = this.device.action_group.connect(
            'action-removed',
            () => this.shortcuts_actions_list.invalidate_filter()
        );
        this._keybindingsId = this.settings.connect(
            'changed::keybindings',
            this._setPluginKeybindings.bind(this)
        );
    }

    _addPluginKeybinding(name) {
        const [icon_name, label] = DEVICE_SHORTCUTS[name];

        const widget = new Gtk.Label({
            label: _('Disabled'),
            visible: true,
        });
        widget.get_style_context().add_class('dim-label');

        const row = new SectionRow({
            height_request: 48,
            icon_name: icon_name,
            title: label,
            widget: widget,
        });
        row.icon_image.pixel_size = 16;
        row.action = name;
        this.shortcuts_actions_list.add(row);
    }

    _filterPluginKeybindings(row) {
        return this.device.action_group.has_action(row.action);
    }

    _setPluginKeybindings() {
        const keybindings = this.settings.get_value('keybindings').deepUnpack();

        this.shortcuts_actions_list.foreach(row => {
            if (keybindings[row.action]) {
                const accel = Gtk.accelerator_parse(keybindings[row.action]);
                row.widget.label = Gtk.accelerator_get_label(...accel);
            } else {
                row.widget.label = _('Disabled');
            }
        });
    }

    _onResetActionShortcuts(button) {
        const keybindings = this.settings.get_value('keybindings').deepUnpack();

        for (const action in keybindings) {
            // Don't reset remote command shortcuts
            if (!action.includes('::'))
                delete keybindings[action];
        }

        this.settings.set_value(
            'keybindings',
            new GLib.Variant('a{ss}', keybindings)
        );
    }

    async _onShortcutRowActivated(box, row) {
        try {
            const keybindings = this.settings.get_value('keybindings').deepUnpack();
            let accel = keybindings[row.action] || null;

            accel = await Keybindings.getAccelerator(row.title, accel);

            if (accel)
                keybindings[row.action] = accel;
            else
                delete keybindings[row.action];

            this.settings.set_value(
                'keybindings',
                new GLib.Variant('a{ss}', keybindings)
            );
        } catch (e) {
            logError(e);
        }
    }

    /**
     * Advanced Page
     */
    _advancedSettings() {
        // Scroll with keyboard focus
        const advanced_box = this.advanced_page.get_child().get_child();
        advanced_box.set_focus_vadjustment(this.advanced_page.vadjustment);

        // Sort & Separate
        this.plugin_list.set_header_func(rowSeparators);
        this.plugin_list.set_sort_func(titleSortFunc);
        this.experimental_list.set_header_func(rowSeparators);

        // Continue focus chain between lists
        this.plugin_list.next = this.experimental_list;
        this.experimental_list.prev = this.plugin_list;

        this._disabledPluginsId = this.settings.connect(
            'changed::disabled-plugins',
            this._onPluginsChanged.bind(this)
        );
        this._supportedPluginsId = this.settings.connect(
            'changed::supported-plugins',
            this._onPluginsChanged.bind(this)
        );
        this._onPluginsChanged(this.settings, null);

        for (const name of DEVICE_PLUGINS)
            this._addPlugin(name);
    }

    _onPluginsChanged(settings, key) {
        if (key === 'disabled-plugins' || this._disabledPlugins === undefined)
            this._disabledPlugins = settings.get_strv('disabled-plugins');

        if (key === 'supported-plugins' || this._supportedPlugins === undefined)
            this._supportedPlugins = settings.get_strv('supported-plugins');

        this._enabledPlugins = this._supportedPlugins.filter(name => {
            return !this._disabledPlugins.includes(name);
        });

        if (key !== null)
            this._updatePlugins();
    }

    _addPlugin(name) {
        const plugin = plugins[name];

        const row = new SectionRow({
            height_request: 48,
            title: plugin.Metadata.label,
            subtitle: plugin.Metadata.description || '',
            visible: this._supportedPlugins.includes(name),
            widget: new Gtk.Switch({
                active: this._enabledPlugins.includes(name),
                valign: Gtk.Align.CENTER,
                vexpand: true,
                visible: true,
            }),
        });
        row.widget.connect('notify::active', this._togglePlugin.bind(this));
        row.set_name(name);

        if (this.hasOwnProperty(name))
            this[name].visible = row.widget.active;

        this.plugin_list.add(row);
    }

    _updatePlugins(settings, key) {
        for (const row of this.plugin_list.get_children()) {
            const name = row.get_name();

            row.visible = this._supportedPlugins.includes(name);
            row.widget.active = this._enabledPlugins.includes(name);

            if (this.hasOwnProperty(name))
                this[name].visible = row.widget.active;
        }
    }

    _togglePlugin(widget) {
        try {
            const name = widget.get_ancestor(Gtk.ListBoxRow.$gtype).get_name();
            const index = this._disabledPlugins.indexOf(name);

            // Either add or remove the plugin from the disabled list
            if (index > -1)
                this._disabledPlugins.splice(index, 1);
            else
                this._disabledPlugins.push(name);

            this.settings.set_strv('disabled-plugins', this._disabledPlugins);
        } catch (e) {
            logError(e);
        }
    }
});
