// 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 Core from './core.js';
import * as DBus from './utils/dbus.js';
import Device from './device.js';

import * as LanBackend from './backends/lan.js';

import {MissingOpensslError} from '../utils/exceptions.js';

const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
const DEVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect/Device';
const DEVICE_IFACE = Config.DBUS.lookup_interface(DEVICE_NAME);


const backends = {
    lan: LanBackend,
};


/**
 * A manager for devices.
 */
const Manager = GObject.registerClass({
    GTypeName: 'GSConnectManager',
    Properties: {
        'active': GObject.ParamSpec.boolean(
            'active',
            'Active',
            'Whether the manager is active',
            GObject.ParamFlags.READABLE,
            false
        ),
        'debug': GObject.ParamSpec.boolean(
            'debug',
            'Debug',
            'Whether debug logging is enabled in GSConnect',
            GObject.ParamFlags.READWRITE,
            false
        ),
        'certificate': GObject.ParamSpec.object(
            'certificate',
            'Certificate',
            'The local TLS certificate',
            GObject.ParamFlags.READABLE,
            Gio.TlsCertificate
        ),
        'discoverable': GObject.ParamSpec.boolean(
            'discoverable',
            'Discoverable',
            'Whether the service responds to discovery requests',
            GObject.ParamFlags.READWRITE,
            false
        ),
        'id': GObject.ParamSpec.string(
            'id',
            'Id',
            'The hostname or other network unique id',
            GObject.ParamFlags.READABLE,
            null
        ),
        'name': GObject.ParamSpec.string(
            'name',
            'Name',
            'The name announced to the network',
            GObject.ParamFlags.READWRITE,
            'GSConnect'
        ),
    },
}, class Manager extends Gio.DBusObjectManagerServer {

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

        this._exported = new WeakMap();
        this._reconnectId = 0;

        this._settings = new Gio.Settings({
            settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
        });
        this._initSettings();
    }

    get active() {
        if (this._active === undefined)
            this._active = false;

        return this._active;
    }

    get backends() {
        if (this._backends === undefined)
            this._backends = new Map();

        return this._backends;
    }

    get certificate() {
        if (this._certificate === undefined) {
            const app = Gio.Application.get_default();
            try {
                this._certificate = Gio.TlsCertificate.new_for_paths(
                    GLib.build_filenamev([Config.CONFIGDIR, 'certificate.pem']),
                    GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
                    null);
                if (this.settings.get_boolean('missing-openssl'))
                    app?.withdraw_notification('gsconnect-missing-openssl');
                this.settings.set_boolean('missing-openssl', false);
            } catch (e) {
                if (e instanceof MissingOpensslError) {
                    this.settings.set_boolean('missing-openssl', true);
                    app?.notify_error(e, 'gsconnect-missing-openssl');
                }
                throw e;
            }
        }

        return this._certificate;
    }

    get debug() {
        if (this._debug === undefined)
            this._debug = this.settings.get_boolean('debug');

        return this._debug;
    }

    set debug(value) {
        if (this._debug === value)
            return;

        this._debug = value;
        this._onDebugChanged(this._debug);
    }

    get devices() {
        if (this._devices === undefined)
            this._devices = new Map();

        return this._devices;
    }

    get discoverable() {
        if (this._discoverable === undefined)
            this._discoverable = this.settings.get_boolean('discoverable');

        return this._discoverable;
    }

    set discoverable(value) {
        if (this.discoverable === value)
            return;

        this._discoverable = value;
        this.notify('discoverable');

        // FIXME: This whole thing just keeps getting uglier
        const application = Gio.Application.get_default();

        if (application === null)
            return;

        if (this.discoverable) {
            Gio.Application.prototype.withdraw_notification.call(
                application,
                'discovery-warning'
            );
        } else {
            const notif = new Gio.Notification();
            notif.set_title(_('Discovery Disabled'));
            notif.set_body(_('Discovery has been disabled due to the number of devices on this network.'));
            notif.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
            notif.set_priority(Gio.NotificationPriority.HIGH);
            notif.set_default_action('app.preferences');
            Gio.Application.prototype.send_notification.call(
                application,
                'discovery-warning',
                notif
            );
        }
    }

    get id() {
        return this.certificate.common_name;
    }

    get name() {
        if (this._name === undefined)
            this._name = this.settings.get_string('name');

        return this._name;
    }

    set name(value) {
        if (this.name === value)
            return;

        this._name = value;
        this.notify('name');

        // Broadcast changes to the network
        for (const backend of this.backends.values()) {
            backend.name = this.name;
            backend.buildIdentity();
        }

        this.identify();
    }

    get settings() {
        if (this._settings === undefined) {
            this._settings = new Gio.Settings({
                settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
            });
        }

        return this._settings;
    }

    vfunc_notify(pspec) {
        if (pspec.name !== 'connection')
            return;

        if (this.connection !== null)
            this._exportDevices();
        else
            this._unexportDevices();
    }

    /*
     * GSettings
     */
    _initSettings() {
        if (this.settings.get_string('name').length === 0)
            this.settings.set_string('name', GLib.get_host_name());

        // Bound Properties
        this.settings.bind('debug', this, 'debug', 0);
        this.settings.bind('discoverable', this, 'discoverable', 0);
        this.settings.bind('name', this, 'name', 0);
    }

    _onDebugChanged(debug = false) {
        // If debugging is disabled, install a no-op for speed
        if (debug && globalThis._debugFunc !== undefined)
            globalThis.debug = globalThis._debugFunc;
        else
            globalThis.debug = () => {};
    }

    /*
     * Backends
     */
    _onChannel(backend, channel) {
        try {
            let device = this.devices.get(channel.identity.body.deviceId);

            switch (true) {
                // Proceed if this is an existing device...
                case (device !== undefined):
                    break;

                // Or the connection is allowed...
                case this.discoverable || channel.allowed:
                    device = this._ensureDevice(channel.identity);
                    break;

                // ...otherwise bail
                default:
                    debug(`${channel.identity.body.deviceName}: not allowed`);
                    return false;
            }

            device.setChannel(channel);
            return true;
        } catch (e) {
            logError(e, backend.name);
            return false;
        }
    }

    _loadBackends() {
        for (const name in backends) {
            try {
                const module = backends[name];

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

                // Try to create the backend and track it if successful
                const backend = new module.ChannelService({
                    id: this.id,
                    name: this.name,
                });
                this.backends.set(name, backend);

                // Connect to the backend
                backend.__channelId = backend.connect(
                    'channel',
                    this._onChannel.bind(this)
                );

                // Now try to start the backend, allowing us to retry if we fail
                backend.start();
            } catch (e) {
                if (Gio.Application.get_default())
                    Gio.Application.get_default().notify_error(e);
            }
        }
    }

    /*
     * Devices
     */
    _loadDevices() {
        // Load cached devices
        for (const id of this.settings.get_strv('devices')) {
            const device = new Device({body: {deviceId: id}});
            this._exportDevice(device);
            this.devices.set(id, device);
        }
    }

    _exportDevice(device) {
        if (this.connection === null)
            return;

        const info = {
            object: null,
            interface: null,
            actions: 0,
            menu: 0,
        };

        const objectPath = `${DEVICE_PATH}/${device.id.replace(/\W+/g, '_')}`;

        // Export an object path for the device
        info.object = new Gio.DBusObjectSkeleton({
            g_object_path: objectPath,
        });
        this.export(info.object);

        // Export GActions & GMenu
        info.actions = Gio.DBus.session.export_action_group(objectPath, device);
        info.menu = Gio.DBus.session.export_menu_model(objectPath, device.menu);

        // Export the Device interface
        info.interface = new DBus.Interface({
            g_instance: device,
            g_interface_info: DEVICE_IFACE,
        });
        info.object.add_interface(info.interface);

        this._exported.set(device, info);
    }

    _exportDevices() {
        if (this.connection === null)
            return;

        for (const device of this.devices.values())
            this._exportDevice(device);
    }

    _unexportDevice(device) {
        const info = this._exported.get(device);

        if (info === undefined)
            return;

        // Unexport GActions and GMenu
        Gio.DBus.session.unexport_action_group(info.actions);
        Gio.DBus.session.unexport_menu_model(info.menu);

        // Unexport the Device interface and object
        info.interface.flush();
        info.object.remove_interface(info.interface);
        info.object.flush();
        this.unexport(info.object.g_object_path);

        this._exported.delete(device);
    }

    _unexportDevices() {
        for (const device of this.devices.values())
            this._unexportDevice(device);
    }

    /**
     * Return a device for @packet, creating it and adding it to the list of
     * of known devices if it doesn't exist.
     *
     * @param {Core.Packet} packet - An identity packet for the device
     * @returns {Device} A device object
     */
    _ensureDevice(packet) {
        let device = this.devices.get(packet.body.deviceId);

        if (device === undefined) {
            debug(`Adding ${packet.body.deviceName}`);

            // TODO: Remove when all clients support bluetooth-like discovery
            //
            // If this is the third unpaired device to connect, we disable
            // discovery to avoid choking on networks with many devices
            const unpaired = Array.from(this.devices.values()).filter(dev => {
                return !dev.paired;
            });

            if (unpaired.length === 3)
                this.discoverable = false;

            device = new Device(packet);
            this._exportDevice(device);
            this.devices.set(device.id, device);

            // Notify
            this.settings.set_strv('devices', Array.from(this.devices.keys()));
        }

        return device;
    }

    /**
     * Permanently remove a device.
     *
     * Removes the device from the list of known devices, deletes all GSettings
     * and files.
     *
     * @param {string} id - The id of the device to delete
     */
    _removeDevice(id) {
        // Delete all GSettings
        const settings_path = `/org/gnome/shell/extensions/gsconnect/device/${id}/`;
        GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`);

        // Delete the cache
        const cache = GLib.build_filenamev([Config.CACHEDIR, id]);
        Gio.File.rm_rf(cache);

        // Forget the device
        this.devices.delete(id);
        this.settings.set_strv('devices', Array.from(this.devices.keys()));
    }

    /**
     * A GSourceFunc that tries to reconnect to each paired device, while
     * pruning unpaired devices that have disconnected.
     *
     * @returns {boolean} Always %true
     */
    _reconnect() {
        for (const [id, device] of this.devices) {
            if (device.connected)
                continue;

            if (device.paired) {
                this.identify(device.settings.get_string('last-connection'));
                continue;
            }

            this._unexportDevice(device);
            this._removeDevice(id);
            device.destroy();
        }

        return GLib.SOURCE_CONTINUE;
    }

    /**
     * Identify to an address or broadcast to the network.
     *
     * @param {string} [uri] - An address URI or %null to broadcast
     */
    identify(uri = null) {
        try {
            // If we're passed a parameter, try and find a backend for it
            if (uri !== null) {
                const [scheme, address] = uri.split('://');

                const backend = this.backends.get(scheme);

                if (backend !== undefined)
                    backend.broadcast(address);

            // If we're not discoverable, only try to reconnect known devices
            } else if (!this.discoverable) {
                this._reconnect();

            // Otherwise have each backend broadcast to it's network
            } else {
                this.backends.forEach(backend => backend.broadcast());
            }
        } catch (e) {
            logError(e);
        }
    }

    /**
     * Start managing devices.
     */
    start() {
        if (this.active)
            return;

        this._loadDevices();
        this._loadBackends();

        if (this._reconnectId === 0) {
            this._reconnectId = GLib.timeout_add_seconds(
                GLib.PRIORITY_LOW,
                5,
                this._reconnect.bind(this)
            );
        }

        this._active = true;
        this.notify('active');
    }

    /**
     * Stop managing devices.
     */
    stop() {
        if (!this.active)
            return;

        if (this._reconnectId > 0) {
            GLib.Source.remove(this._reconnectId);
            this._reconnectId = 0;
        }

        this._unexportDevices();

        this.backends.forEach(backend => backend.destroy());
        this.backends.clear();

        this.devices.forEach(device => device.destroy());
        this.devices.clear();

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

    /**
     * Stop managing devices and free any resources.
     */
    destroy() {
        this.stop();
        this.set_connection(null);
    }
});

export default Manager;
