'use strict';

const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;

const Config = imports.config;
const Lan = imports.service.backends.lan;
const PluginBase = imports.service.plugin;


var 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
 */
var Plugin = GObject.registerClass({
    GTypeName: 'GSConnectSFTPPlugin',
}, class Plugin extends PluginBase.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 instanceof Lan.Channel) {
            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 new Promise((resolve, reject) => {
            file.enumerate_children_async(
                Gio.FILE_ATTRIBUTE_STANDARD_NAME,
                Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
                GLib.PRIORITY_DEFAULT,
                this.cancellable,
                (file, res) => {
                    try {
                        resolve(file.enumerate_children_finish(res));
                    } catch (e) {
                        reject(e);
                    }
                }
            );
        });

        const infos = await new Promise((resolve, reject) => {
            iter.next_files_async(
                MAX_MOUNT_DIRS,
                GLib.PRIORITY_DEFAULT,
                this.cancellable,
                (iter, res) => {
                    try {
                        resolve(iter.next_files_finish(res));
                    } catch (e) {
                        reject(e);
                    }
                }
            );
        });

        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 new Promise((resolve, reject) => {
                file.mount_enclosing_volume(0, op, null, (file, res) => {
                    try {
                        resolve(file.mount_enclosing_volume_finish(res));
                    } 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)) {
                            resolve(true);

                        // There's a good chance this is a host key verification
                        // error; regardless we'll remove the key for security.
                        } else {
                            this._removeHostKey(host);
                            reject(e);
                        }
                    }
                });
            });
        } catch (e) {
            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.
     *
     * @return {Promise} A promise for the operation
     */
    _addPrivateKey() {
        const ssh_add = this._launcher.spawnv([
            Config.SSHADD_PATH,
            GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
        ]);

        return new Promise((resolve, reject) => {
            ssh_add.communicate_utf8_async(null, null, (proc, res) => {
                try {
                    const result = proc.communicate_utf8_finish(res)[1].trim();

                    if (proc.get_exit_status() !== 0)
                        debug(result, this.device.name);

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

    /**
     * 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}`,
                ]);

                await new Promise((resolve, reject) => {
                    ssh_keygen.communicate_utf8_async(null, null, (proc, res) => {
                        try {
                            const stdout = proc.communicate_utf8_finish(res)[1];
                            const status = proc.get_exit_status();

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

                            resolve();
                        } catch (e) {
                            reject(e);
                        }
                    });
                });
            } 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 new Promise((resolve, reject) => {
                    link.query_info_async(
                        'standard::symlink-target',
                        Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
                        GLib.PRIORITY_DEFAULT,
                        null,
                        (link, res) => {
                            try {
                                resolve(link.query_info_finish(res));
                            } catch (e) {
                                reject(e);
                            }
                        }
                    );
                });

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

                await new Promise((resolve, reject) => {
                    link.delete_async(
                        GLib.PRIORITY_DEFAULT,
                        null,
                        (link, res) => {
                            try {
                                resolve(link.delete_finish(res));
                            } catch (e) {
                                reject(e);
                            }
                        }
                    );
                });
            } catch (e) {
                if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
                    throw e;
            }

            link.make_symbolic_link(link_target, null);
        } 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 new Promise((resolve, reject) => {
                this.gmount.unmount_with_operation(
                    Gio.MountUnmountFlags.FORCE,
                    new Gio.MountOperation(),
                    null,
                    (mount, res) => {
                        try {
                            resolve(mount.unmount_with_operation_finish(res));
                        } catch (e) {
                            reject(e);
                        }
                    }
                );
            });
        } 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();
    }
});
