// 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 Plugin from '../plugin.js';


export const Metadata = {
    label: _('SFTP'),
    id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
    description: _('Browse the paired device filesystem'),
    incomingCapabilities: ['kdeconnect.sftp'],
    outgoingCapabilities: ['kdeconnect.sftp.request'],
    actions: {
        mount: {
            label: _('Mount'),
            icon_name: 'folder-remote-symbolic',

            parameter_type: null,
            incoming: ['kdeconnect.sftp'],
            outgoing: ['kdeconnect.sftp.request'],
        },
        unmount: {
            label: _('Unmount'),
            icon_name: 'media-eject-symbolic',

            parameter_type: null,
            incoming: ['kdeconnect.sftp'],
            outgoing: ['kdeconnect.sftp.request'],
        },
    },
};


const MAX_MOUNT_DIRS = 12;


/**
 * SFTP Plugin
 * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
 * https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
 */
const SFTPPlugin = GObject.registerClass({
    GTypeName: 'GSConnectSFTPPlugin',
}, class SFTPPlugin extends Plugin {

    _init(device) {
        super._init(device, 'sftp');

        this._gmount = null;
        this._mounting = false;

        // A reusable launcher for ssh processes
        this._launcher = new Gio.SubprocessLauncher({
            flags: (Gio.SubprocessFlags.STDOUT_PIPE |
                    Gio.SubprocessFlags.STDERR_MERGE),
        });

        // Watch the volume monitor
        this._volumeMonitor = Gio.VolumeMonitor.get();

        this._mountAddedId = this._volumeMonitor.connect(
            'mount-added',
            this._onMountAdded.bind(this)
        );

        this._mountRemovedId = this._volumeMonitor.connect(
            'mount-removed',
            this._onMountRemoved.bind(this)
        );
    }

    get gmount() {
        if (this._gmount === null && this.device.connected) {
            const host = this.device.channel.host;

            const regex = new RegExp(
                `sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
            );

            for (const mount of this._volumeMonitor.get_mounts()) {
                const uri = mount.get_root().get_uri();

                if (regex.test(uri)) {
                    this._gmount = mount;
                    this._addSubmenu(mount);
                    this._addSymlink(mount);

                    break;
                }
            }
        }

        return this._gmount;
    }

    connected() {
        super.connected();

        // Only enable for Lan connections
        if (this.device.channel.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
            if (this.settings.get_boolean('automount'))
                this.mount();
        } else {
            this.device.lookup_action('mount').enabled = false;
            this.device.lookup_action('unmount').enabled = false;
        }
    }

    handlePacket(packet) {
        switch (packet.type) {
            case 'kdeconnect.sftp':
                if (packet.body.hasOwnProperty('errorMessage'))
                    this._handleError(packet);
                else
                    this._handleMount(packet);

                break;
        }
    }

    _onMountAdded(monitor, mount) {
        if (this._gmount !== null || !this.device.connected)
            return;

        const host = this.device.channel.host;
        const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
        const uri = mount.get_root().get_uri();

        if (!regex.test(uri))
            return;

        this._gmount = mount;
        this._addSubmenu(mount);
        this._addSymlink(mount);
    }

    _onMountRemoved(monitor, mount) {
        if (this.gmount !== mount)
            return;

        this._gmount = null;
        this._removeSubmenu();
    }

    async _listDirectories(mount) {
        const file = mount.get_root();

        const iter = await file.enumerate_children_async(
            Gio.FILE_ATTRIBUTE_STANDARD_NAME,
            Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
            GLib.PRIORITY_DEFAULT,
            this.cancellable);

        const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
            GLib.PRIORITY_DEFAULT, this.cancellable);
        iter.close_async(GLib.PRIORITY_DEFAULT, null, null);

        const directories = {};

        for (const info of infos) {
            const name = info.get_name();
            directories[name] = `${file.get_uri()}${name}/`;
        }

        return directories;
    }

    _onAskQuestion(op, message, choices) {
        op.reply(Gio.MountOperationResult.HANDLED);
    }

    _onAskPassword(op, message, user, domain, flags) {
        op.reply(Gio.MountOperationResult.HANDLED);
    }

    /**
     * Handle an error reported by the remote device.
     *
     * @param {Core.Packet} packet - a `kdeconnect.sftp`
     */
    _handleError(packet) {
        this.device.showNotification({
            id: 'sftp-error',
            title: _('%s reported an error').format(this.device.name),
            body: packet.body.errorMessage,
            icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
            priority: Gio.NotificationPriority.HIGH,
        });
    }

    /**
     * Mount the remote device using the provided information.
     *
     * @param {Core.Packet} packet - a `kdeconnect.sftp`
     */
    async _handleMount(packet) {
        try {
            // Already mounted or mounting
            if (this.gmount !== null || this._mounting)
                return;

            this._mounting = true;

            // Ensure the private key is in the keyring
            await this._addPrivateKey();

            // Create a new mount operation
            const op = new Gio.MountOperation({
                username: packet.body.user || null,
                password: packet.body.password || null,
                password_save: Gio.PasswordSave.NEVER,
            });

            op.connect('ask-question', this._onAskQuestion);
            op.connect('ask-password', this._onAskPassword);

            // This is the actual call to mount the device
            const host = this.device.channel.host;
            const uri = `sftp://${host}:${packet.body.port}/`;
            const file = Gio.File.new_for_uri(uri);

            await file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
                this.cancellable);
        } catch (e) {
            // Special case when the GMount didn't unmount properly but is still
            // on the same port and can be reused.
            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED))
                return;

            // There's a good chance this is a host key verification error;
            // regardless we'll remove the key for security.
            this._removeHostKey(this.device.channel.host);
            logError(e, this.device.name);
        } finally {
            this._mounting = false;
        }
    }

    /**
     * Add GSConnect's private key identity to the authentication agent so our
     * identity can be verified by Android during private key authentication.
     *
     * @returns {Promise} A promise for the operation
     */
    async _addPrivateKey() {
        const ssh_add = this._launcher.spawnv([
            Config.SSHADD_PATH,
            GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
        ]);

        const [stdout] = await ssh_add.communicate_utf8_async(null,
            this.cancellable);

        if (ssh_add.get_exit_status() !== 0)
            debug(stdout.trim(), this.device.name);
    }

    /**
     * Remove all host keys from ~/.ssh/known_hosts for @host in the port range
     * used by KDE Connect (1739-1764).
     *
     * @param {string} host - A hostname or IP address
     */
    async _removeHostKey(host) {
        for (let port = 1739; port <= 1764; port++) {
            try {
                const ssh_keygen = this._launcher.spawnv([
                    Config.SSHKEYGEN_PATH,
                    '-R',
                    `[${host}]:${port}`,
                ]);

                const [stdout] = await ssh_keygen.communicate_utf8_async(null,
                    this.cancellable);

                const status = ssh_keygen.get_exit_status();

                if (status !== 0) {
                    throw new Gio.IOErrorEnum({
                        code: Gio.io_error_from_errno(status),
                        message: `${GLib.strerror(status)}\n${stdout}`.trim(),
                    });
                }
            } catch (e) {
                logError(e, this.device.name);
            }
        }
    }

    /*
     * Mount menu helpers
     */
    _getUnmountSection() {
        if (this._unmountSection === undefined) {
            this._unmountSection = new Gio.Menu();

            const unmountItem = new Gio.MenuItem();
            unmountItem.set_label(Metadata.actions.unmount.label);
            unmountItem.set_icon(new Gio.ThemedIcon({
                name: Metadata.actions.unmount.icon_name,
            }));
            unmountItem.set_detailed_action('device.unmount');
            this._unmountSection.append_item(unmountItem);
        }

        return this._unmountSection;
    }

    _getFilesMenuItem() {
        if (this._filesMenuItem === undefined) {
            // Files menu icon
            const emblem = new Gio.Emblem({
                icon: new Gio.ThemedIcon({name: 'emblem-default'}),
            });

            const mountedIcon = new Gio.EmblemedIcon({
                gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
            });
            mountedIcon.add_emblem(emblem);

            // Files menu item
            this._filesMenuItem = new Gio.MenuItem();
            this._filesMenuItem.set_detailed_action('device.mount');
            this._filesMenuItem.set_icon(mountedIcon);
            this._filesMenuItem.set_label(_('Files'));
        }

        return this._filesMenuItem;
    }

    async _addSubmenu(mount) {
        try {
            const directories = await this._listDirectories(mount);

            // Submenu sections
            const dirSection = new Gio.Menu();
            const unmountSection = this._getUnmountSection();

            for (const [name, uri] of Object.entries(directories))
                dirSection.append(name, `device.openPath::${uri}`);

            // Files submenu
            const filesSubmenu = new Gio.Menu();
            filesSubmenu.append_section(null, dirSection);
            filesSubmenu.append_section(null, unmountSection);

            // Files menu item
            const filesMenuItem = this._getFilesMenuItem();
            filesMenuItem.set_submenu(filesSubmenu);

            // Replace the existing menu item
            const index = this.device.removeMenuAction('device.mount');
            this.device.addMenuItem(filesMenuItem, index);
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                debug(e, this.device.name);

            // Reset to allow retrying
            this._gmount = null;
        }
    }

    _removeSubmenu() {
        try {
            const index = this.device.removeMenuAction('device.mount');
            const action = this.device.lookup_action('mount');

            if (action !== null) {
                this.device.addMenuAction(
                    action,
                    index,
                    Metadata.actions.mount.label,
                    Metadata.actions.mount.icon_name
                );
            }
        } catch (e) {
            logError(e, this.device.name);
        }
    }

    /**
     * Create a symbolic link referring to the device by name
     *
     * @param {Gio.Mount} mount - A GMount to link to
     */
    async _addSymlink(mount) {
        try {
            const by_name_dir = Gio.File.new_for_path(
                `${Config.RUNTIMEDIR}/by-name/`
            );

            try {
                by_name_dir.make_directory_with_parents(null);
            } catch (e) {
                if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
                    throw e;
            }

            // Replace path separator with a Unicode lookalike:
            let safe_device_name = this.device.name.replace('/', '∕');

            if (safe_device_name === '.')
                safe_device_name = '·';
            else if (safe_device_name === '..')
                safe_device_name = '··';

            const link_target = mount.get_root().get_path();
            const link = Gio.File.new_for_path(
                `${by_name_dir.get_path()}/${safe_device_name}`);

            // Check for and remove any existing stale link
            try {
                const link_stat = await link.query_info_async(
                    'standard::symlink-target',
                    Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
                    GLib.PRIORITY_DEFAULT,
                    this.cancellable);

                if (link_stat.get_symlink_target() === link_target)
                    return;

                await link.delete_async(GLib.PRIORITY_DEFAULT,
                    this.cancellable);
            } catch (e) {
                if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
                    throw e;
            }

            link.make_symbolic_link(link_target, this.cancellable);
        } catch (e) {
            debug(e, this.device.name);
        }
    }

    /**
     * Send a request to mount the remote device
     */
    mount() {
        if (this.gmount !== null)
            return;

        this.device.sendPacket({
            type: 'kdeconnect.sftp.request',
            body: {
                startBrowsing: true,
            },
        });
    }

    /**
     * Remove the menu items, unmount the filesystem, replace the mount item
     */
    async unmount() {
        try {
            if (this.gmount === null)
                return;

            this._removeSubmenu();
            this._mounting = false;

            await this.gmount.unmount_with_operation(
                Gio.MountUnmountFlags.FORCE,
                new Gio.MountOperation(),
                this.cancellable);
        } catch (e) {
            debug(e, this.device.name);
        }
    }

    destroy() {
        if (this._volumeMonitor) {
            this._volumeMonitor.disconnect(this._mountAddedId);
            this._volumeMonitor.disconnect(this._mountRemovedId);
            this._volumeMonitor = null;
        }

        super.destroy();
    }
});

export default SFTPPlugin;
