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

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

import Meta from 'gi://Meta';


/*
 * DBus Interface Info
 */
const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(`
<node>
  <interface name="org.gnome.Shell.Extensions.GSConnect.Clipboard">
    <!-- Methods -->
    <method name="GetMimetypes">
      <arg direction="out" type="as" name="mimetypes"/>
    </method>
    <method name="GetText">
      <arg direction="out" type="s" name="text"/>
    </method>
    <method name="SetText">
      <arg direction="in" type="s" name="text"/>
    </method>
    <method name="GetValue">
      <arg direction="in" type="s" name="mimetype"/>
      <arg direction="out" type="ay" name="value"/>
    </method>
    <method name="SetValue">
      <arg direction="in" type="ay" name="value"/>
      <arg direction="in" type="s" name="mimetype"/>
    </method>

    <!-- Signals -->
    <signal name="OwnerChange"/>
  </interface>
</node>
`);
const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);


/*
 * Text Mimetypes
 */
const TEXT_MIMETYPES = [
    'text/plain;charset=utf-8',
    'UTF8_STRING',
    'text/plain',
    'STRING',
];


/* GSConnectClipboardPortal:
 *
 * A simple clipboard portal, especially useful on Wayland where GtkClipboard
 * doesn't work in the background.
 */
export const Clipboard = GObject.registerClass({
    GTypeName: 'GSConnectShellClipboard',
}, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {

    _init(params = {}) {
        super._init({
            g_interface_info: DBUS_INFO,
        });

        this._transferring = false;

        // Watch global selection
        this._selection = global.display.get_selection();
        this._ownerChangedId = this._selection.connect(
            'owner-changed',
            this._onOwnerChanged.bind(this)
        );

        // Prepare DBus interface
        this._handleMethodCallId = this.connect(
            'handle-method-call',
            this._onHandleMethodCall.bind(this)
        );

        this._nameId = Gio.DBus.own_name(
            Gio.BusType.SESSION,
            DBUS_NAME,
            Gio.BusNameOwnerFlags.NONE,
            this._onBusAcquired.bind(this),
            null,
            this._onNameLost.bind(this)
        );
    }

    _onOwnerChanged(selection, type, source) {
        /* We're only interested in the standard clipboard */
        if (type !== Meta.SelectionType.SELECTION_CLIPBOARD)
            return;

        /* In Wayland an intermediate GMemoryOutputStream is used which triggers
         * a second ::owner-changed emission, so we need to ensure we ignore
         * that while the transfer is resolving.
         */
        if (this._transferring)
            return;

        this._transferring = true;

        /* We need to put our signal emission in an idle callback to ensure that
         * Mutter's internal calls have finished resolving in the loop, or else
         * we'll end up with the previous selection's content.
         */
        GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
            this.emit_signal('OwnerChange', null);
            this._transferring = false;

            return GLib.SOURCE_REMOVE;
        });
    }

    _onBusAcquired(connection, name) {
        try {
            this.export(connection, DBUS_PATH);
        } catch (e) {
            logError(e);
        }
    }

    _onNameLost(connection, name) {
        try {
            this.unexport();
        } catch (e) {
            logError(e);
        }
    }

    async _onHandleMethodCall(iface, name, parameters, invocation) {
        let retval;

        try {
            const args = parameters.recursiveUnpack();

            retval = await this[name](...args);
        } catch (e) {
            if (e instanceof GLib.Error) {
                invocation.return_gerror(e);
            } else {
                if (!e.name.includes('.'))
                    e.name = `org.gnome.gjs.JSError.${e.name}`;

                invocation.return_dbus_error(e.name, e.message);
            }

            return;
        }

        if (retval === undefined)
            retval = new GLib.Variant('()', []);

        try {
            if (!(retval instanceof GLib.Variant)) {
                const args = DBUS_INFO.lookup_method(name).out_args;
                retval = new GLib.Variant(
                    `(${args.map(arg => arg.signature).join('')})`,
                    (args.length === 1) ? [retval] : retval
                );
            }

            invocation.return_value(retval);

        // Without a response, the client will wait for timeout
        } catch {
            invocation.return_dbus_error(
                'org.gnome.gjs.JSError.ValueError',
                'Service implementation returned an incorrect value type'
            );
        }
    }

    /**
     * Get the available mimetypes of the current clipboard content
     *
     * @returns {Promise<string[]>} A list of mime-types
     */
    GetMimetypes() {
        return new Promise((resolve, reject) => {
            try {
                const mimetypes = this._selection.get_mimetypes(
                    Meta.SelectionType.SELECTION_CLIPBOARD
                );

                resolve(mimetypes);
            } catch (e) {
                reject(e);
            }
        });
    }

    /**
     * Get the text content of the clipboard
     *
     * @returns {Promise<string>} Text content of the clipboard
     */
    GetText() {
        return new Promise((resolve, reject) => {
            const mimetypes = this._selection.get_mimetypes(
                Meta.SelectionType.SELECTION_CLIPBOARD);

            const mimetype = TEXT_MIMETYPES.find(type => mimetypes.includes(type));

            if (mimetype !== undefined) {
                const stream = Gio.MemoryOutputStream.new_resizable();

                this._selection.transfer_async(
                    Meta.SelectionType.SELECTION_CLIPBOARD,
                    mimetype, -1,
                    stream, null,
                    (selection, res) => {
                        try {
                            selection.transfer_finish(res);

                            const bytes = stream.steal_as_bytes();
                            const bytearray = bytes.get_data();

                            resolve(new TextDecoder().decode(bytearray));
                        } catch (e) {
                            reject(e);
                        }
                    }
                );
            } else {
                reject(new Error('text not available'));
            }
        });
    }

    /**
     * Set the text content of the clipboard
     *
     * @param {string} text - text content to set
     * @returns {Promise} A promise for the operation
     */
    SetText(text) {
        return new Promise((resolve, reject) => {
            try {
                if (typeof text !== 'string') {
                    throw new Gio.DBusError({
                        code: Gio.DBusError.INVALID_ARGS,
                        message: 'expected string',
                    });
                }

                const source = Meta.SelectionSourceMemory.new(
                    'text/plain;charset=utf-8', GLib.Bytes.new(text));

                this._selection.set_owner(
                    Meta.SelectionType.SELECTION_CLIPBOARD, source);

                resolve();
            } catch (e) {
                reject(e);
            }
        });
    }

    /**
     * Get the content of the clipboard with the type @mimetype.
     *
     * @param {string} mimetype - the mimetype to request
     * @returns {Promise<Uint8Array>} The content of the clipboard
     */
    GetValue(mimetype) {
        return new Promise((resolve, reject) => {
            const stream = Gio.MemoryOutputStream.new_resizable();

            this._selection.transfer_async(
                Meta.SelectionType.SELECTION_CLIPBOARD,
                mimetype, -1,
                stream, null,
                (selection, res) => {
                    try {
                        selection.transfer_finish(res);

                        const bytes = stream.steal_as_bytes();

                        resolve(bytes.get_data());
                    } catch (e) {
                        reject(e);
                    }
                }
            );
        });
    }

    /**
     * Set the content of the clipboard to @value with the type @mimetype.
     *
     * @param {Uint8Array} value - the value to set
     * @param {string} mimetype - the mimetype of the value
     * @returns {Promise} - A promise for the operation
     */
    SetValue(value, mimetype) {
        return new Promise((resolve, reject) => {
            try {
                const source = Meta.SelectionSourceMemory.new(mimetype,
                    GLib.Bytes.new(value));

                this._selection.set_owner(
                    Meta.SelectionType.SELECTION_CLIPBOARD, source);

                resolve();
            } catch (e) {
                reject(e);
            }
        });
    }

    destroy() {
        if (this._selection && this._ownerChangedId > 0) {
            this._selection.disconnect(this._ownerChangedId);
            this._ownerChangedId = 0;
        }

        if (this._nameId > 0) {
            Gio.bus_unown_name(this._nameId);
            this._nameId = 0;
        }

        if (this._handleMethodCallId > 0) {
            this.disconnect(this._handleMethodCallId);
            this._handleMethodCallId = 0;
            this.unexport();
        }
    }
});


let _portal = null;
let _portalId = 0;

/**
 * Watch for the service to start and export the clipboard portal when it does.
 */
export function watchService() {
    if (GLib.getenv('XDG_SESSION_TYPE') !== 'wayland')
        return;

    if (_portalId > 0)
        return;

    _portalId = Gio.bus_watch_name(
        Gio.BusType.SESSION,
        'org.gnome.Shell.Extensions.GSConnect',
        Gio.BusNameWatcherFlags.NONE,
        () => {
            if (_portal === null)
                _portal = new Clipboard();
        },
        () => {
            if (_portal !== null) {
                _portal.destroy();
                _portal = null;
            }
        }
    );
}

/**
 * Stop watching the service and export the portal if currently running.
 */
export function unwatchService() {
    if (_portalId > 0) {
        Gio.bus_unwatch_name(_portalId);
        _portalId = 0;
    }
}
