// 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';

const SERVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect';
const SERVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect';
const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';


const _PROPERTIES = {
    'Connected': 'connected',
    'EncryptionInfo': 'encryption-info',
    'IconName': 'icon-name',
    'Id': 'id',
    'Name': 'name',
    'Paired': 'paired',
    'Type': 'type',
};


/**
 * Initialize a Gio.DBusProxy in an awaitable manner
 *
 * @param {Gio.DBusProxy} proxy - The proxy object to initialize
 * @param {Gio.Cancellable} [cancellable] - An optional cancellable object
 * @returns {Promise} An awaitable Promise
 */
function _proxyInit(proxy, cancellable = null) {
    if (proxy.__initialized !== undefined)
        return Promise.resolve();

    return new Promise((resolve, reject) => {
        proxy.init_async(
            GLib.PRIORITY_DEFAULT,
            cancellable,
            (proxy, res) => {
                try {
                    proxy.init_finish(res);
                    proxy.__initialized = true;
                    resolve();
                } catch (e) {
                    Gio.DBusError.strip_remote_error(e);
                    reject(e);
                }
            }
        );
    });
}


/**
 * A simple proxy wrapper for devices exported over DBus.
 */
export const Device = GObject.registerClass({
    GTypeName: 'GSConnectRemoteDevice',
    Implements: [Gio.DBusInterface],
    Properties: {
        'connected': GObject.ParamSpec.boolean(
            'connected',
            'Connected',
            'Whether the device is connected',
            GObject.ParamFlags.READABLE,
            null
        ),
        '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',
            'deviceId',
            'The device hostname or other unique id',
            GObject.ParamFlags.READABLE,
            ''
        ),
        'name': GObject.ParamSpec.string(
            'name',
            'deviceName',
            'The device name',
            GObject.ParamFlags.READABLE,
            null
        ),
        'paired': GObject.ParamSpec.boolean(
            'paired',
            'Paired',
            'Whether the device is paired',
            GObject.ParamFlags.READABLE,
            null
        ),
        'type': GObject.ParamSpec.string(
            'type',
            'deviceType',
            'The device type',
            GObject.ParamFlags.READABLE,
            null
        ),
    },
}, class Device extends Gio.DBusProxy {

    _init(service, object_path) {
        this._service = service;

        super._init({
            g_connection: service.g_connection,
            g_name: SERVICE_NAME,
            g_object_path: object_path,
            g_interface_name: DEVICE_NAME,
        });
    }

    vfunc_g_properties_changed(changed, invalidated) {
        try {
            for (const name in changed.deepUnpack())
                this.notify(_PROPERTIES[name]);
        } catch (e) {
            logError(e);
        }
    }

    _get(name, fallback = null) {
        try {
            return this.get_cached_property(name).unpack();
        } catch {
            return fallback;
        }
    }

    get connected() {
        return this._get('Connected', false);
    }

    get encryption_info() {
        return this._get('EncryptionInfo', '');
    }

    get icon_name() {
        return this._get('IconName', 'computer');
    }

    get id() {
        return this._get('Id', '0');
    }

    get name() {
        return this._get('Name', 'Unknown');
    }

    get paired() {
        return this._get('Paired', false);
    }

    get service() {
        return this._service;
    }

    get type() {
        return this._get('Type', 'desktop');
    }

    async start() {
        try {
            await _proxyInit(this);

            // For GActions & GMenu we pass the service's name owner to avoid
            // any mixup with instances.
            this.action_group = Gio.DBusActionGroup.get(
                this.g_connection,
                this.service.g_name_owner,
                this.g_object_path
            );

            this.menu = Gio.DBusMenuModel.get(
                this.g_connection,
                this.service.g_name_owner,
                this.g_object_path
            );

            // Poke the GMenu to ensure it's ready for us
            await new Promise((resolve, reject) => {
                this.g_connection.call(
                    SERVICE_NAME,
                    this.g_object_path,
                    'org.gtk.Menus',
                    'Start',
                    new GLib.Variant('(au)', [[0]]),
                    null,
                    Gio.DBusCallFlags.NONE,
                    -1,
                    null,
                    (proxy, res) => {
                        try {
                            resolve(proxy.call_finish(res));
                        } catch (e) {
                            Gio.DBusError.strip_remote_error(e);
                            reject(e);
                        }
                    }
                );
            });
        } catch (e) {
            this.destroy();
            throw e;
        }
    }

    destroy() {
        GObject.signal_handlers_destroy(this);
    }
});


/**
 * A simple proxy wrapper for the GSConnect service.
 */
export const Service = GObject.registerClass({
    GTypeName: 'GSConnectRemoteService',
    Implements: [Gio.DBusInterface],
    Properties: {
        'active': GObject.ParamSpec.boolean(
            'active',
            'Active',
            'Whether the service is active',
            GObject.ParamFlags.READABLE,
            false
        ),
    },
    Signals: {
        'device-added': {
            flags: GObject.SignalFlags.RUN_FIRST,
            param_types: [Device.$gtype],
        },
        'device-removed': {
            flags: GObject.SignalFlags.RUN_FIRST,
            param_types: [Device.$gtype],
        },
    },
}, class Service extends Gio.DBusProxy {

    _init() {
        super._init({
            g_bus_type: Gio.BusType.SESSION,
            g_name: SERVICE_NAME,
            g_object_path: SERVICE_PATH,
            g_interface_name: 'org.freedesktop.DBus.ObjectManager',
            g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
        });

        this._active = false;
        this._devices = new Map();
        this._starting = false;

        // Watch the service
        this._nameOwnerChangedId = this.connect(
            'notify::g-name-owner',
            this._onNameOwnerChanged.bind(this)
        );
    }

    get active() {
        return this._active;
    }

    get devices() {
        return Array.from(this._devices.values());
    }

    vfunc_g_signal(sender_name, signal_name, parameters) {
        try {
            // Don't emit signals until the ObjectManager has started
            if (!this.active)
                return;

            parameters = parameters.deepUnpack();

            switch (true) {
                case (signal_name === 'InterfacesAdded'):
                    this._onInterfacesAdded(...parameters);
                    break;

                case (signal_name === 'InterfacesRemoved'):
                    this._onInterfacesRemoved(...parameters);
                    break;
            }
        } catch (e) {
            logError(e);
        }
    }

    /**
     * org.freedesktop.DBus.ObjectManager.InterfacesAdded
     *
     * @param {string} object_path - Path interfaces have been added to
     * @param {object} interfaces - A dictionary of interface objects
     */
    async _onInterfacesAdded(object_path, interfaces) {
        try {
            // An empty list means only the object has been added
            if (Object.values(interfaces).length === 0)
                return;

            // Skip existing proxies
            if (this._devices.has(object_path))
                return;

            // Create a proxy
            const device = new Device(this, object_path);
            await device.start();

            // Hold the proxy and emit ::device-added
            this._devices.set(object_path, device);
            this.emit('device-added', device);
        } catch (e) {
            logError(e, object_path);
        }
    }

    /**
     * org.freedesktop.DBus.ObjectManager.InterfacesRemoved
     *
     * @param {string} object_path - Path interfaces have been removed from
     * @param {string[]} interfaces - List of interface names removed
     */
    _onInterfacesRemoved(object_path, interfaces) {
        try {
            // An empty interface list means the object is being removed
            if (interfaces.length === 0)
                return;

            // Get the proxy
            const device = this._devices.get(object_path);

            if (device === undefined)
                return;

            // Release the proxy and emit ::device-removed
            this._devices.delete(object_path);
            this.emit('device-removed', device);

            // Destroy the device and force disposal
            device.destroy();
        } catch (e) {
            logError(e, object_path);
        }
    }

    async _addDevices() {
        const objects = await new Promise((resolve, reject) => {
            this.call(
                'GetManagedObjects',
                null,
                Gio.DBusCallFlags.NONE,
                -1,
                null,
                (proxy, res) => {
                    try {
                        const variant = proxy.call_finish(res);
                        resolve(variant.deepUnpack()[0]);
                    } catch (e) {
                        Gio.DBusError.strip_remote_error(e);
                        reject(e);
                    }
                }
            );
        });

        for (const [object_path, object] of Object.entries(objects))
            await this._onInterfacesAdded(object_path, object);
    }

    _clearDevices() {
        for (const [object_path, device] of this._devices) {
            this._devices.delete(object_path);
            this.emit('device-removed', device);
            device.destroy();
        }
    }

    async _onNameOwnerChanged() {
        try {
            // If the service stopped, remove each device and mark it inactive
            if (this.g_name_owner === null) {
                this._clearDevices();

                this._active = false;
                this.notify('active');

            // If the service started, mark it active and add each device
            } else {
                this._active = true;
                this.notify('active');

                await this._addDevices();
            }
        } catch (e) {
            logError(e);
        }
    }

    /**
     * Reload all devices without affecting the remote service. This amounts to
     * removing and adding each device while emitting the appropriate signals.
     */
    async reload() {
        try {
            if (this._starting === false) {
                this._starting = true;

                this._clearDevices();
                await _proxyInit(this);
                await this._onNameOwnerChanged();

                this._starting = false;
            }
        } catch (e) {
            this._starting = false;
            throw e;
        }
    }

    /**
     * Start the service
     */
    async start() {
        try {
            if (this._starting === false && this.active === false) {
                this._starting = true;

                await _proxyInit(this);
                await this._onNameOwnerChanged();

                // Activate the service if it's not already running
                if (!this.active) {
                    await new Promise((resolve, reject) => {
                        this.g_connection.call(
                            SERVICE_NAME,
                            SERVICE_PATH,
                            'org.freedesktop.Application',
                            'Activate',
                            GLib.Variant.new('(a{sv})', [{}]),
                            null,
                            Gio.DBusCallFlags.NONE,
                            -1,
                            null,
                            (proxy, res) => {
                                try {
                                    resolve(proxy.call_finish(res));
                                } catch (e) {
                                    Gio.DBusError.strip_remote_error(e);
                                    reject(e);
                                }
                            }
                        );
                    });
                }

                this._starting = false;
            }
        } catch (e) {
            this._starting = false;
            throw e;
        }
    }

    /**
     * Stop the service
     */
    stop() {
        if (this.active)
            this.activate_action('quit');
    }

    activate_action(name, parameter = null) {
        try {
            const paramArray = [];

            if (parameter instanceof GLib.Variant)
                paramArray[0] = parameter;

            const connection = this.g_connection || Gio.DBus.session;

            connection.call(
                SERVICE_NAME,
                SERVICE_PATH,
                'org.freedesktop.Application',
                'ActivateAction',
                GLib.Variant.new('(sava{sv})', [name, paramArray, {}]),
                null,
                Gio.DBusCallFlags.NONE,
                -1,
                null,
                null
            );
        } catch (e) {
            logError(e);
        }
    }

    destroy() {
        if (this._nameOwnerChangedId > 0) {
            this.disconnect(this._nameOwnerChangedId);
            this._nameOwnerChangedId = 0;

            this._clearDevices();
            this._active = false;

            GObject.signal_handlers_destroy(this);
        }
    }
});
