// 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 Config from '../config.js';
import * as Components from './components/index.js';
import * as Core from './core.js';
import plugins from './plugins/index.js';

const ALLOWED_TIMESTAMP_TIME_DIFFERENCE_SECONDS = 1800; // 30 min

/**
 * An object representing a remote device.
 *
 * Device class is subclassed from Gio.SimpleActionGroup so it implements the
 * GActionGroup and GActionMap interfaces, like Gio.Application.
 *
 */
const Device = GObject.registerClass({
    GTypeName: 'GSConnectDevice',
    Properties: {
        'connected': GObject.ParamSpec.boolean(
            'connected',
            'Connected',
            'Whether the device is connected',
            GObject.ParamFlags.READABLE,
            false
        ),
        'contacts': GObject.ParamSpec.object(
            'contacts',
            'Contacts',
            'The contacts store for this device',
            GObject.ParamFlags.READABLE,
            GObject.Object
        ),
        'encryption-info': GObject.ParamSpec.string(
            'encryption-info',
            'Encryption Info',
            'A formatted string with the local and remote fingerprints',
            GObject.ParamFlags.READABLE,
            null
        ),
        'icon-name': GObject.ParamSpec.string(
            'icon-name',
            'Icon Name',
            'Icon name representing the device',
            GObject.ParamFlags.READABLE,
            null
        ),
        'id': GObject.ParamSpec.string(
            'id',
            'Id',
            'The device hostname or other network unique id',
            GObject.ParamFlags.READABLE,
            ''
        ),
        'name': GObject.ParamSpec.string(
            'name',
            'Name',
            'The device name',
            GObject.ParamFlags.READABLE,
            null
        ),
        'paired': GObject.ParamSpec.boolean(
            'paired',
            'Paired',
            'Whether the device is paired',
            GObject.ParamFlags.READABLE,
            false
        ),
        'type': GObject.ParamSpec.string(
            'type',
            'Type',
            'The device type',
            GObject.ParamFlags.READABLE,
            null
        ),
    },
}, class Device extends Gio.SimpleActionGroup {

    _init(identity) {
        super._init();

        this._id = identity.body.deviceId;

        // GLib.Source timeout id's for pairing requests
        this._incomingPairRequest = 0;
        this._outgoingPairRequest = 0;
        this._pairingTimestamp = 0;

        // Maps of name->Plugin, packet->Plugin, uuid->Transfer
        this._plugins = new Map();
        this._handlers = new Map();
        this._procs = new Set();
        this._transfers = new Map();

        this._outputLock = false;
        this._outputQueue = [];

        // 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/${this.id}/`,
        });
        this._migratePlugins();

        // Watch for changes to supported and disabled plugins
        this._disabledPluginsChangedId = this.settings.connect(
            'changed::disabled-plugins',
            this._onAllowedPluginsChanged.bind(this)
        );
        this._supportedPluginsChangedId = this.settings.connect(
            'changed::supported-plugins',
            this._onAllowedPluginsChanged.bind(this)
        );

        this._registerActions();
        this.menu = new Gio.Menu();

        // Parse identity if initialized with a proper packet, otherwise load
        if (identity.id !== undefined)
            this._handleIdentity(identity);
        else
            this._loadPlugins();
    }

    static generateId() {
        return GLib.uuid_string_random().replaceAll('-', '');
    }

    static validateId(id) {
        return /^[a-zA-Z0-9_-]{32,38}$/.test(id);
    }

    static validateName(name) {
        // None of the forbidden characters and at least one non-whitespace
        return name.trim() && /^[^"',;:.!?()[\]<>]{1,32}$/.test(name);
    }

    get channel() {
        if (this._channel === undefined)
            this._channel = null;

        return this._channel;
    }

    get connected() {
        if (this._connected === undefined)
            this._connected = false;

        return this._connected;
    }

    get connection_type() {
        const lastConnection = this.settings.get_string('last-connection');

        return lastConnection.split('://')[0];
    }

    get contacts() {
        const contacts = this._plugins.get('contacts');

        if (contacts && contacts.settings.get_boolean('contacts-source'))
            return contacts._store;

        if (this._contacts === undefined)
            this._contacts = Components.acquire('contacts');

        return this._contacts;
    }

    // FIXME: backend should do this stuff
    get encryption_info() {
        if (!this.channel)
            return '';

        // Bluetooth connections have no certificate so we use the host address
        if (this.connection_type === 'bluetooth') {
            // TRANSLATORS: Bluetooth address for remote device
            return _('Bluetooth device at %s').format('???');
        }

        // FIXME: another ugly reach-around
        const localCert = this.service.manager.backends.get('lan')?.certificate;
        const remoteCert = this.channel?.peer_certificate;
        if (!localCert || !remoteCert)
            return '';

        const checksum = new GLib.Checksum(GLib.ChecksumType.SHA256);
        let [a, b] = [localCert.pubkey_der(), remoteCert.pubkey_der()];
        if (a.compare(b) < 0)
            [a, b] = [b, a]; // swap
        checksum.update(a.toArray());
        checksum.update(b.toArray());

        if (this.channel?.identity.body.protocolVersion >= 8)
            checksum.update(String(this._pairingTimestamp));

        const verificationKey = checksum.get_string()
            .substring(0, 8)
            .toUpperCase();

        // TRANSLATORS: Label for TLS connection verification key
        //
        // Example:
        //
        // Verification key: 0123456789abcdef000000000000000000000000
        return _('Verification key: %s').format(verificationKey);
    }

    get id() {
        return this._id;
    }

    get name() {
        return this.settings.get_string('name');
    }

    get paired() {
        return this.settings.get_boolean('paired');
    }

    get icon_name() {
        switch (this.type) {
            case 'laptop':
                return 'laptop-symbolic';
            case 'phone':
                return 'smartphone-symbolic';
            case 'tablet':
                return 'tablet-symbolic';
            case 'tv':
                return 'tv-symbolic';
            case 'desktop':
            default:
                return 'computer-symbolic';
        }
    }

    get service() {
        if (this._service === undefined)
            this._service = Gio.Application.get_default();

        return this._service;
    }

    get type() {
        return this.settings.get_string('type');
    }

    _migratePlugins() {
        const deprecated = ['photo'];
        const supported = this.settings
            .get_strv('supported-plugins')
            .filter(name => !deprecated.includes(name));

        this.settings.set_strv('supported-plugins', supported);
    }

    _handleIdentity(packet) {
        this.freeze_notify();

        // If we're connected, record the reconnect URI
        if (this.channel !== null)
            this.settings.set_string('last-connection', this.channel.address);

        // The type won't change, but it might not be properly set yet
        if (this.type !== packet.body.deviceType) {
            this.settings.set_string('type', packet.body.deviceType);
            this.notify('type');
            this.notify('icon-name');
        }

        // The name may change so we check and notify if so
        if (this.name !== packet.body.deviceName) {
            this.settings.set_string('name', packet.body.deviceName);
            this.notify('name');
        }

        // Packets
        const incoming = packet.body.incomingCapabilities.sort();
        const outgoing = packet.body.outgoingCapabilities.sort();
        const inc = this.settings.get_strv('incoming-capabilities');
        const out = this.settings.get_strv('outgoing-capabilities');

        // Only write GSettings if something has changed
        if (incoming.join('') !== inc.join('') || outgoing.join('') !== out.join('')) {
            this.settings.set_strv('incoming-capabilities', incoming);
            this.settings.set_strv('outgoing-capabilities', outgoing);
        }

        // Determine supported plugins by matching incoming to outgoing types
        const supported = [];

        for (const name in plugins) {
            const meta = plugins[name].Metadata;

            if (meta === undefined)
                continue;

            // If we can handle packets it sends or send packets it can handle
            if (meta.incomingCapabilities.some(t => outgoing.includes(t)) ||
                meta.outgoingCapabilities.some(t => incoming.includes(t)))
                supported.push(name);
        }

        // Only write GSettings if something has changed
        const currentSupported = this.settings.get_strv('supported-plugins');

        if (currentSupported.join('') !== supported.sort().join(''))
            this.settings.set_strv('supported-plugins', supported);

        this.thaw_notify();
    }

    /**
     * Set the channel and start sending/receiving packets. If %null is passed
     * the device becomes disconnected.
     *
     * @param {Core.Channel} [channel] - The new channel
     */
    setChannel(channel = null) {
        if (this.channel === channel)
            return;

        if (this.channel !== null)
            this.channel.close();

        this._channel = channel;

        // If we've disconnected empty the queue, otherwise restart the read
        // loop and update the device metadata
        if (this.channel === null) {
            this._outputQueue.length = 0;
        } else {
            this._handleIdentity(this.channel.identity);
            this._readLoop(channel);
        }

        // The connected state didn't change
        if (this.connected === !!this.channel)
            return;

        // Notify and trigger plugins
        this._connected = !!this.channel;
        this.notify('connected');
        this._triggerPlugins();
    }

    async _readLoop(channel) {
        try {
            let packet = null;

            while ((packet = await this.channel.readPacket())) {
                debug(packet, this.name);
                this.handlePacket(packet);
            }
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                debug(e, this.name);

            if (this.channel === channel)
                this.setChannel(null);
        }
    }

    _processExit(proc, result) {
        try {
            proc.wait_check_finish(result);
        } catch (e) {
            debug(e);
        }

        this.delete(proc);
    }

    /**
     * Launch a subprocess for the device. If the device becomes unpaired, it is
     * assumed the device is no longer trusted and all subprocesses will be
     * killed.
     *
     * @param {string[]} args - process arguments
     * @param {Gio.Cancellable} [cancellable] - optional cancellable
     * @returns {Gio.Subprocess} The subprocess
     */
    launchProcess(args, cancellable = null) {
        if (this._launcher === undefined) {
            const application = GLib.build_filenamev([
                Config.PACKAGE_DATADIR,
                'service',
                'daemon.js',
            ]);

            this._launcher = new Gio.SubprocessLauncher();
            this._launcher.setenv('GSCONNECT', application, false);
            this._launcher.setenv('GSCONNECT_DEVICE_ID', this.id, false);
            this._launcher.setenv('GSCONNECT_DEVICE_NAME', this.name, false);
            this._launcher.setenv('GSCONNECT_DEVICE_ICON', this.icon_name, false);
            this._launcher.setenv(
                'GSCONNECT_DEVICE_DBUS',
                `${Config.APP_PATH}/Device/${this.id.replace(/\W+/g, '_')}`,
                false
            );
        }

        // Create and track the process
        const proc = this._launcher.spawnv(args);
        proc.wait_check_async(cancellable, this._processExit.bind(this._procs));
        this._procs.add(proc);

        return proc;
    }

    /**
     * Handle a packet and pass it to the appropriate plugin.
     *
     * @param {Core.Packet} packet - The incoming packet object
     * @returns {undefined} no return value
     */
    handlePacket(packet) {
        try {
            if (packet.type === 'kdeconnect.pair')
                return this._handlePair(packet);

            // The device must think we're paired; inform it we are not
            if (!this.paired)
                return this.unpair();

            const handler = this._handlers.get(packet.type);

            if (handler !== undefined)
                handler.handlePacket(packet);
            else
                debug(`Unsupported packet type (${packet.type})`, this.name);
        } catch (e) {
            debug(e, this.name);
        }
    }

    /**
     * Send a packet to the device.
     *
     * @param {object} packet - An object of packet data...
     */
    async sendPacket(packet) {
        try {
            if (!this.connected)
                return;

            if (!this.paired && packet.type !== 'kdeconnect.pair')
                return;

            this._outputQueue.push(new Core.Packet(packet));

            if (this._outputLock)
                return;

            this._outputLock = true;
            let next;

            while ((next = this._outputQueue.shift())) {
                await this.channel.sendPacket(next);
                debug(next, this.name);
            }

            this._outputLock = false;
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                debug(e, this.name);

            this._outputLock = false;
        }
    }

    /**
     * Actions
     */
    _registerActions() {
        // Pairing notification actions
        const acceptPair = new Gio.SimpleAction({name: 'pair'});
        acceptPair.connect('activate', this.pair.bind(this));
        this.add_action(acceptPair);

        const rejectPair = new Gio.SimpleAction({name: 'unpair'});
        rejectPair.connect('activate', this.unpair.bind(this));
        this.add_action(rejectPair);

        // Transfer notification actions
        const cancelTransfer = new Gio.SimpleAction({
            name: 'cancelTransfer',
            parameter_type: new GLib.VariantType('s'),
        });
        cancelTransfer.connect('activate', this.cancelTransfer.bind(this));
        this.add_action(cancelTransfer);

        const openPath = new Gio.SimpleAction({
            name: 'openPath',
            parameter_type: new GLib.VariantType('s'),
        });
        openPath.connect('activate', this.openPath);
        this.add_action(openPath);

        const showPathInFolder = new Gio.SimpleAction({
            name: 'showPathInFolder',
            parameter_type: new GLib.VariantType('s'),
        });
        showPathInFolder.connect('activate', this.showPathInFolder);
        this.add_action(showPathInFolder);

        // Preference helpers
        const clearCache = new Gio.SimpleAction({
            name: 'clearCache',
            parameter_type: null,
        });
        clearCache.connect('activate', this._clearCache.bind(this));
        this.add_action(clearCache);
    }

    /**
     * Get the position of a GMenuItem with @actionName in the top level of the
     * device menu.
     *
     * @param {string} actionName - An action name with scope (eg. device.foo)
     * @returns {number} An 0-based index or -1 if not found
     */
    getMenuAction(actionName) {
        for (let i = 0, len = this.menu.get_n_items(); i < len; i++) {
            try {
                const val = this.menu.get_item_attribute_value(i, 'action', null);

                if (val.unpack() === actionName)
                    return i;
            } catch {
                continue;
            }
        }

        return -1;
    }

    /**
     * Add a GMenuItem to the top level of the device menu
     *
     * @param {Gio.MenuItem} menuItem - A GMenuItem
     * @param {number} [index] - The position to place the item
     * @returns {number} The position the item was placed
     */
    addMenuItem(menuItem, index = -1) {
        try {
            if (index > -1) {
                this.menu.insert_item(index, menuItem);
                return index;
            }

            this.menu.append_item(menuItem);
            return this.menu.get_n_items();
        } catch (e) {
            debug(e, this.name);
            return -1;
        }
    }

    /**
     * Add a Device GAction to the top level of the device menu
     *
     * @param {Gio.Action} action - A GAction
     * @param {number} [index] - The position to place the item
     * @param {string} label - A label for the item
     * @param {string} icon_name - A themed icon name for the item
     * @returns {number} The position the item was placed
     */
    addMenuAction(action, index = -1, label, icon_name) {
        try {
            const item = new Gio.MenuItem();

            if (label)
                item.set_label(label);

            if (icon_name)
                item.set_icon(new Gio.ThemedIcon({name: icon_name}));

            item.set_attribute_value(
                'hidden-when',
                new GLib.Variant('s', 'action-disabled')
            );

            item.set_detailed_action(`device.${action.name}`);

            return this.addMenuItem(item, index);
        } catch (e) {
            debug(e, this.name);
            return -1;
        }
    }

    /**
     * Remove a GAction from the top level of the device menu by action name
     *
     * @param {string} actionName - A GAction name, including scope
     * @returns {number} The position the item was removed from or -1
     */
    removeMenuAction(actionName) {
        try {
            const index = this.getMenuAction(actionName);

            if (index > -1)
                this.menu.remove(index);

            return index;
        } catch (e) {
            debug(e, this.name);
            return -1;
        }
    }

    /**
     * Withdraw a device notification.
     *
     * @param {string} id - Id for the notification to withdraw
     */
    hideNotification(id) {
        if (this.service === null)
            return;

        this.service.withdraw_notification(`${this.id}|${id}`);
    }

    /**
     * Show a device notification.
     *
     * @param {object} params - A dictionary of notification parameters
     * @param {number} [params.id] - A UNIX epoch timestamp (ms)
     * @param {string} [params.title] - A title
     * @param {string} [params.body] - A body
     * @param {Gio.Icon} [params.icon] - An icon
     * @param {Gio.NotificationPriority} [params.priority] - The priority
     * @param {Array} [params.actions] - A dictionary of action parameters
     * @param {Array} [params.buttons] - An Array of buttons
     */
    showNotification(params) {
        if (this.service === null)
            return;

        // KDE Connect on Android can sometimes give an undefined for params.body
        Object.keys(params)
            .forEach(key => params[key] === undefined && delete params[key]);

        params = Object.assign({
            id: Date.now(),
            title: this.name,
            body: '',
            icon: new Gio.ThemedIcon({name: this.icon_name}),
            priority: Gio.NotificationPriority.NORMAL,
            action: null,
            buttons: [],
        }, params);

        const notif = new Gio.Notification();
        notif.set_title(params.title);
        notif.set_body(params.body);
        notif.set_icon(params.icon);
        notif.set_priority(params.priority);

        // Default Action
        if (params.action) {
            const hasParameter = (params.action.parameter !== null);

            if (!hasParameter)
                params.action.parameter = new GLib.Variant('s', '');

            notif.set_default_action_and_target(
                'app.device',
                new GLib.Variant('(ssbv)', [
                    this.id,
                    params.action.name,
                    hasParameter,
                    params.action.parameter,
                ])
            );
        }

        // Buttons
        for (const button of params.buttons) {
            const hasParameter = (button.parameter !== null);

            if (!hasParameter)
                button.parameter = new GLib.Variant('s', '');

            notif.add_button_with_target(
                button.label,
                'app.device',
                new GLib.Variant('(ssbv)', [
                    this.id,
                    button.action,
                    hasParameter,
                    button.parameter,
                ])
            );
        }

        this.service.send_notification(`${this.id}|${params.id}`, notif);
    }

    /**
     * Cancel an ongoing file transfer.
     *
     * @param {Gio.Action} action - The GAction
     * @param {GLib.Variant} parameter - The activation parameter
     */
    cancelTransfer(action, parameter) {
        try {
            const uuid = parameter.unpack();
            const transfer = this._transfers.get(uuid);

            if (transfer === undefined)
                return;

            this._transfers.delete(uuid);
            transfer.cancel();
        } catch (e) {
            logError(e, this.name);
        }
    }

    /**
     * Create a transfer object.
     *
     * @returns {Core.Transfer} A new transfer
     */
    createTransfer() {
        const transfer = new Core.Transfer({device: this});

        // Track the transfer
        this._transfers.set(transfer.uuid, transfer);

        transfer.connect('notify::completed', (transfer) => {
            this._transfers.delete(transfer.uuid);
        });

        return transfer;
    }

    /**
     * Reject the transfer payload described by @packet.
     *
     * @param {Core.Packet} packet - A packet
     * @returns {void}
     */
    rejectTransfer(packet) {
        if (packet?.hasPayload())
            return this.channel.rejectTransfer(packet);
    }

    openPath(action, parameter) {
        const path = parameter.unpack();

        // Normalize paths to URIs, assuming local file
        const uri = path.includes('://') ? path : `file://${path}`;
        Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
    }

    showPathInFolder(action, parameter) {
        const path = parameter.unpack();
        const uri = path.includes('://') ? path : `file://${path}`;

        const connection = Gio.DBus.session;
        connection.call(
            'org.freedesktop.FileManager1',
            '/org/freedesktop/FileManager1',
            'org.freedesktop.FileManager1',
            'ShowItems',
            new GLib.Variant('(ass)', [[uri], 's']),
            null,
            Gio.DBusCallFlags.NONE,
            -1,
            null,
            (connection, res) => {
                try {
                    connection.call_finish(res);
                } catch (e) {
                    Gio.DBusError.strip_remote_error(e);
                    logError(e);
                }
            }
        );
    }

    _clearCache(action, parameter) {
        for (const plugin of this._plugins.values()) {
            try {
                plugin.clearCache();
            } catch (e) {
                debug(e, this.name);
            }
        }
    }

    /**
     * Pair request handler
     *
     * @param {Core.Packet} packet - A complete kdeconnect.pair packet
     */
    _handlePair(packet) {
        // A pair has been requested/confirmed
        if (packet.body.pair) {
            // The device is accepting our request
            if (this._outgoingPairRequest) {
                this._setPaired(true);
                this._loadPlugins();

            // The device thinks we're unpaired
            } else if (this.paired) {
                this._setPaired(true);
                this._pairingTimestamp = Math.floor(Date.now() / 1000);
                this.sendPacket({
                    type: 'kdeconnect.pair',
                    body: {
                        pair: true,
                        timestamp: this._pairingTimestamp,
                    },
                });
                this._triggerPlugins();

            // The device is requesting pairing
            } else {
                this._notifyPairRequest(packet.body?.timestamp);
            }
        // Device is requesting unpairing/rejecting our request
        } else {
            this._setPaired(false);
            this._unloadPlugins();
        }
    }

    /**
     * Notify the user of an incoming pair request and set a 30s timeout
     *
     * @param {number} [timestamp] - Timestamp for the pair request
     */
    _notifyPairRequest(timestamp = 0) {
        // Reset any active request
        this._resetPairRequest();

        this._pairingTimestamp = timestamp;
        this.showNotification({
            id: 'pair-request',
            // TRANSLATORS: eg. Pair Request from Google Pixel
            title: _('Pair Request from %s').format(this.name),
            body: this.encryption_info,
            icon: new Gio.ThemedIcon({name: 'channel-insecure-symbolic'}),
            priority: Gio.NotificationPriority.URGENT,
            buttons: [
                {
                    action: 'unpair',
                    label: _('Reject'),
                    parameter: null,
                },
                {
                    action: 'pair',
                    label: _('Accept'),
                    parameter: null,
                },
            ],
        });

        // Start a 30s countdown
        this._incomingPairRequest = GLib.timeout_add_seconds(
            GLib.PRIORITY_DEFAULT,
            30,
            this._setPaired.bind(this, false)
        );
    }

    /**
     * Reset pair request timeouts and withdraw any notifications
     */
    _resetPairRequest() {
        this.hideNotification('pair-request');

        if (this._incomingPairRequest) {
            GLib.source_remove(this._incomingPairRequest);
            this._incomingPairRequest = 0;
        }

        if (this._outgoingPairRequest) {
            GLib.source_remove(this._outgoingPairRequest);
            this._outgoingPairRequest = 0;
        }

        this._pairingTimestamp = 0;
    }

    /**
     * Set the internal paired state of the device and emit ::notify
     *
     * @param {boolean} paired - The paired state to set
     */
    _setPaired(paired) {
        this._resetPairRequest();

        // For TCP connections we store or reset the TLS Certificate
        if (this.connection_type === 'lan') {
            if (paired) {
                this.settings.set_string(
                    'certificate-pem',
                    this.channel.peer_certificate.certificate_pem
                );
            } else {
                this.settings.reset('certificate-pem');
            }
        }

        // If we've become unpaired, stop all subprocesses and transfers
        if (!paired) {
            for (const proc of this._procs)
                proc.force_exit();

            this._procs.clear();

            for (const transfer of this._transfers.values())
                transfer.close();

            this._transfers.clear();
        }

        this.settings.set_boolean('paired', paired);
        this.notify('paired');
    }

    /**
     * Send or accept an incoming pair request; also exported as a GAction
     */
    pair() {
        try {
            // If we're accepting an incoming pair request, set the internal
            // paired state and send the confirmation before loading plugins.
            if (this._incomingPairRequest) {
                if (this.identity?.body.protocolVersion >= 8) {
                    const currentTimestamp = Math.floor(Date.now() / 1000);
                    const diffTimestamp = Number.abs(this._pairingTimestamp - currentTimestamp);
                    if (diffTimestamp > ALLOWED_TIMESTAMP_TIME_DIFFERENCE_SECONDS) {
                        this._setPaired(false);
                        this.showNotification({
                            id: 'pair-request',
                            // TRANSLATORS: eg. Failed to pair with Google Pixel
                            title: _('Failed to pair with %s').format(this.name),
                            body: _('Device clocks are out of sync'),
                            icon: new Gio.ThemedIcon({name: 'dialog-warning-symbolic'}),
                            priority: Gio.NotificationPriority.URGENT,
                        });
                        return;
                    }
                }

                this._setPaired(true);
                this.sendPacket({
                    type: 'kdeconnect.pair',
                    body: {pair: true},
                });

                this._loadPlugins();

            // If we're initiating an outgoing pair request, be sure the timer
            // is reset before sending the request and setting a 30s timeout.
            } else if (!this.paired) {
                this._resetPairRequest();

                this._pairingTimestamp = Math.floor(Date.now() / 1000);
                this.sendPacket({
                    type: 'kdeconnect.pair',
                    body: {
                        pair: true,
                        timestamp: this._pairingTimestamp,
                    },
                });

                this._outgoingPairRequest = GLib.timeout_add_seconds(
                    GLib.PRIORITY_DEFAULT,
                    30,
                    this._setPaired.bind(this, false)
                );
            }
        } catch (e) {
            logError(e, this.name);
        }
    }

    /**
     * Unpair or reject an incoming pair request; also exported as a GAction
     */
    unpair() {
        try {
            if (this.connected) {
                this.sendPacket({
                    type: 'kdeconnect.pair',
                    body: {pair: false},
                });
            }

            this._setPaired(false);
            this._unloadPlugins();
        } catch (e) {
            logError(e, this.name);
        }
    }

    /*
     * Plugin Functions
     */
    _onAllowedPluginsChanged(settings) {
        const disabled = this.settings.get_strv('disabled-plugins');
        const supported = this.settings.get_strv('supported-plugins');
        const allowed = supported.filter(name => !disabled.includes(name));

        // Unload any plugins that are disabled or unsupported
        this._plugins.forEach(plugin => {
            if (!allowed.includes(plugin.name))
                this._unloadPlugin(plugin.name);
        });

        // Make sure we change the contacts store if the plugin was disabled
        if (!allowed.includes('contacts'))
            this.notify('contacts');

        // Load allowed plugins
        for (const name of allowed)
            this._loadPlugin(name);
    }

    _loadPlugin(name) {
        let handler, plugin;

        try {
            if (this.paired && !this._plugins.has(name)) {
                // Instantiate the handler
                handler = plugins[name];
                plugin = new handler.default(this);

                // Register packet handlers
                for (const packetType of handler.Metadata.incomingCapabilities)
                    this._handlers.set(packetType, plugin);

                // Register plugin
                this._plugins.set(name, plugin);

                // Run the connected()/disconnected() handler
                if (this.connected)
                    plugin.connected();
                else
                    plugin.disconnected();
            }
        } catch (e) {
            if (plugin !== undefined)
                plugin.destroy();

            if (this.service !== null)
                this.service.notify_error(e);
            else
                logError(e, this.name);
        }
    }

    async _loadPlugins() {
        const disabled = this.settings.get_strv('disabled-plugins');

        for (const name of this.settings.get_strv('supported-plugins')) {
            if (!disabled.includes(name))
                await this._loadPlugin(name);
        }
    }

    _unloadPlugin(name) {
        let handler, plugin;

        try {
            if (this._plugins.has(name)) {
                // Unregister packet handlers
                handler = plugins[name];

                for (const type of handler.Metadata.incomingCapabilities)
                    this._handlers.delete(type);

                // Unregister plugin
                plugin = this._plugins.get(name);
                this._plugins.delete(name);
                plugin.destroy();
            }
        } catch (e) {
            logError(e, this.name);
        }
    }

    async _unloadPlugins() {
        for (const name of this._plugins.keys())
            await this._unloadPlugin(name);
    }

    _triggerPlugins() {
        for (const plugin of this._plugins.values()) {
            if (this.connected)
                plugin.connected();
            else
                plugin.disconnected();
        }
    }

    destroy() {
        // Drop the default contacts store if we were using it
        if (this._contacts !== undefined)
            this._contacts = Components.release('contacts');

        // Close the channel if still connected
        if (this.channel !== null)
            this.channel.close();

        // Synchronously destroy plugins
        this._plugins.forEach(plugin => plugin.destroy());
        this._plugins.clear();

        // Dispose GSettings
        this.settings.disconnect(this._disabledPluginsChangedId);
        this.settings.disconnect(this._supportedPluginsChangedId);
        this.settings.run_dispose();

        GObject.signal_handlers_destroy(this);
    }
});

export default Device;
