// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-

import {
    Gio,
    GLib,
    GObject,
    Shell,
    St,
} from './dependencies/gi.js';

import {ShellMountOperation} from './dependencies/shell/ui.js';

import {
    Docking,
    Utils,
} from './imports.js';

import {Extension} from './dependencies/shell/extensions/extension.js';

// Use __ () and N__() for the extension gettext domain, and reuse
// the shell domain with the default _() and N_()
const {gettext: __} = Extension;

const {signals: Signals} = imports;

const FALLBACK_REMOVABLE_MEDIA_ICON = 'drive-removable-media';
const FALLBACK_TRASH_ICON = 'user-trash';
const FILE_MANAGER_DESKTOP_APP_ID = 'org.gnome.Nautilus.desktop';
const ATTRIBUTE_METADATA_CUSTOM_ICON = 'metadata::custom-icon';
const TRASH_URI = 'trash://';
const UPDATE_TRASH_DELAY = 1000;
const LAUNCH_HANDLER_MAX_WAIT = 200;

const NautilusFileOperations2Interface = '<node>\
    <interface name="org.gnome.Nautilus.FileOperations2">\
        <method name="EmptyTrash">\
            <arg type="b" name="ask_confirmation" direction="in"/>\
            <arg type="a{sv}" name="platform_data" direction="in"/>\
        </method>\
    </interface>\
</node>';

const NautilusFileOperations2ProxyInterface =
    Gio.DBusProxy.makeProxyWrapper(NautilusFileOperations2Interface);

const Labels = Object.freeze({
    LOCATION_WINDOWS: Symbol('location-windows'),
    WINDOWS_CHANGED: Symbol('windows-changed'),
});

const GJS_SUPPORTS_FILE_IFACE_PROMISES = imports.system.version >= 17101;

if (GJS_SUPPORTS_FILE_IFACE_PROMISES) {
    Gio._promisify(Gio.File.prototype, 'query_info_async');
    Gio._promisify(Gio.File.prototype, 'query_default_handler_async');
}


/**
 *
 */
function makeNautilusFileOperationsProxy() {
    const proxy = new NautilusFileOperations2ProxyInterface(
        Gio.DBus.session,
        'org.gnome.Nautilus',
        '/org/gnome/Nautilus/FileOperations2', (_p, error) => {
            if (error)
                logError(error, 'Error connecting to Nautilus');
        }
    );

    proxy.platformData = params => {
        const defaultParams = {
            parentHandle: '',
            timestamp: global.get_current_time(),
            windowPosition: 'center',
        };
        const {parentHandle, timestamp, windowPosition} = {
            ...defaultParams,
            ...params,
        };

        return {
            'parent-handle': new GLib.Variant('s', parentHandle),
            'timestamp': new GLib.Variant('u', timestamp),
            'window-position': new GLib.Variant('s', windowPosition),
        };
    };

    return proxy;
}

export const LocationAppInfo = GObject.registerClass({
    Implements: [Gio.AppInfo],
    Properties: {
        'location': GObject.ParamSpec.object(
            'location', 'location', 'location',
            GObject.ParamFlags.READWRITE,
            Gio.File.$gtype),
        'name': GObject.ParamSpec.string(
            'name', 'name', 'name',
            GObject.ParamFlags.READWRITE,
            null),
        'icon': GObject.ParamSpec.object(
            'icon', 'icon', 'icon',
            GObject.ParamFlags.READWRITE,
            Gio.Icon.$gtype),
        'cancellable': GObject.ParamSpec.object(
            'cancellable', 'cancellable', 'cancellable',
            GObject.ParamFlags.READWRITE,
            Gio.Cancellable.$gtype),
    },
}, class LocationAppInfo extends Gio.DesktopAppInfo {
    static get GJS_BINARY_PATH() {
        if (!this._gjsBinaryPath)
            this._gjsBinaryPath = GLib.find_program_in_path('gjs');

        return this._gjsBinaryPath;
    }

    list_actions() {
        return [];
    }

    get_action_name() {
        return null;
    }

    get_boolean() {
        return false;
    }

    vfunc_dup() {
        return new LocationAppInfo({
            location: this.location,
            name: this.name,
            icon: this.icon,
            cancellable: this.cancellable,
        });
    }

    vfunc_equal(other) {
        if (this.location)
            return this.location.equal(other?.location);

        return this.name === other.name &&
            (this.icon ? this.icon.equal(other?.icon) : !other?.icon);
    }

    vfunc_get_id() {
        return 'location:%s'.format(this.location?.get_uri());
    }

    vfunc_get_name() {
        return this.name;
    }

    vfunc_get_description() {
        return null;
    }

    vfunc_get_executable() {
        return null;
    }

    vfunc_get_icon() {
        return this.icon;
    }

    vfunc_launch(files, context) {
        if (files?.length) {
            throw new GLib.Error(Gio.IOErrorEnum,
                Gio.IOErrorEnum.NOT_SUPPORTED, 'Launching with files not supported');
        }

        return this.getHandlerApp().launch_uris([this.location.get_uri()], context);
    }

    vfunc_supports_uris() {
        return false;
    }

    vfunc_supports_files() {
        return false;
    }

    vfunc_launch_uris(uris, context) {
        return this.launch(uris, context);
    }

    vfunc_should_show() {
        return true;
    }

    vfunc_set_as_default_for_type() {
        throw new GLib.Error(Gio.IOErrorEnum,
            Gio.IOErrorEnum.NOT_SUPPORTED, 'Not supported');
    }

    vfunc_set_as_default_for_extension() {
        throw new GLib.Error(Gio.IOErrorEnum,
            Gio.IOErrorEnum.NOT_SUPPORTED, 'Not supported');
    }

    vfunc_add_supports_type() {
        throw new GLib.Error(Gio.IOErrorEnum,
            Gio.IOErrorEnum.NOT_SUPPORTED, 'Not supported');
    }

    vfunc_can_remove_supports_type() {
        return false;
    }

    vfunc_remove_supports_type() {
        return false;
    }

    vfunc_can_delete() {
        return false;
    }

    vfunc_do_delete() {
        return false;
    }

    vfunc_get_commandline() {
        try {
            return this.getHandlerApp().get_commandline();
        } catch {
            return this._getFallbackCommandLine();
        }
    }

    vfunc_get_display_name() {
        return this.name;
    }

    vfunc_set_as_last_used_for_type() {
        throw new GLib.Error(Gio.IOErrorEnum,
            Gio.IOErrorEnum.NOT_SUPPORTED, 'Not supported');
    }

    vfunc_get_supported_types() {
        return [];
    }

    _getFallbackCommandLine() {
        return `gio open ${this.location?.get_uri()}`;
    }

    async _queryLocationIcons(params) {
        const icons = {standard: null, custom: null};
        if (!this.location)
            return icons;

        const cancellable = params.cancellable ?? this.cancellable;
        const iconsQuery = [];
        if (params?.standard)
            iconsQuery.push(Gio.FILE_ATTRIBUTE_STANDARD_ICON);

        if (params?.custom)
            iconsQuery.push(ATTRIBUTE_METADATA_CUSTOM_ICON);

        if (!iconsQuery.length)
            throw new Error('Invalid Query Location Icons parameters');

        let info;
        try {
            if (!GJS_SUPPORTS_FILE_IFACE_PROMISES) {
                Gio._promisify(this.location.constructor.prototype,
                    'query_info_async', 'query_info_finish');
            }
            info = await this.location.query_info_async(
                iconsQuery.join(','),
                Gio.FileQueryInfoFlags.NONE,
                GLib.PRIORITY_LOW, cancellable);
            if (info.has_attribute(Gio.FILE_ATTRIBUTE_STANDARD_ICON))
                icons.standard = info.get_icon();
        } catch (e) {
            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) ||
                e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_MOUNTED))
                return icons;
            throw e;
        }

        const customIcon = info.get_attribute_string(ATTRIBUTE_METADATA_CUSTOM_ICON);
        if (customIcon) {
            const customIconFile = GLib.uri_parse_scheme(customIcon)
                ? Gio.File.new_for_uri(customIcon) : Gio.File.new_for_path(customIcon);
            const iconFileInfo = await customIconFile.query_info_async(
                Gio.FILE_ATTRIBUTE_STANDARD_TYPE,
                Gio.FileQueryInfoFlags.NONE,
                GLib.PRIORITY_LOW, cancellable);

            if (iconFileInfo.get_file_type() === Gio.FileType.REGULAR)
                icons.custom = Gio.FileIcon.new(customIconFile);
        }

        return icons;
    }

    async _updateLocationIcon(params = {standard: true, custom: true}) {
        const cancellable = new Utils.CancellableChild(this.cancellable);

        try {
            this._updateIconCancellable?.cancel();
            this._updateIconCancellable = cancellable;

            const icons = await this._queryLocationIcons({cancellable, ...params});
            const icon = icons.custom ?? icons.standard;

            if (icon && !icon.equal(this.icon))
                this.icon = icon;
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                logError(e, 'Impossible to update icon for %s'.format(this.get_id()));
        } finally {
            cancellable.cancel();
            if (this._updateIconCancellable === cancellable)
                delete this._updateIconCancellable;
        }
    }

    async _getHandlerAppAsync(cancellable) {
        if (!this.location)
            return null;

        try {
            if (!GJS_SUPPORTS_FILE_IFACE_PROMISES) {
                Gio._promisify(this.location.constructor.prototype,
                    'query_default_handler_async',
                    'query_default_handler_finish');
            }

            return await this.location.query_default_handler_async(
                GLib.PRIORITY_DEFAULT, cancellable);
        } catch (e) {
            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_MOUNTED))
                return getFileManagerApp()?.appInfo;

            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
                logError(e, 'Impossible to find an URI handler for %s'.format(
                    this.get_id()));
            }

            throw e;
        }
    }

    _getHandlerAppFromWorker(cancellable) {
        const locationsWorker = GLib.build_filenamev([
            Docking.DockManager.extension.path,
            'locationsWorker.js',
        ]);
        const locationsWorkerArgs = [LocationAppInfo.GJS_BINARY_PATH, '-m',
            locationsWorker, 'handler', this.location.get_uri(),
            '--timeout', `${LAUNCH_HANDLER_MAX_WAIT}`];
        const subProcess = Gio.Subprocess.new(locationsWorkerArgs,
            Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);

        try {
            const [, stdOut, stdErr] = subProcess.communicate(null, cancellable);
            subProcess.wait(cancellable);
            const errorCode = subProcess.get_exit_status();
            const textDecoder = new TextDecoder();

            if (errorCode) {
                const errorLines = textDecoder.decode(stdErr.toArray()).split('\n');
                const error = new GLib.Error(Gio.IOErrorEnum,
                    errorCode === GLib.MAXUINT8 ? 0 : errorCode, errorLines[0]);
                error.stack = `${errorLines.slice(3).join('\n')}${error.stack}`;
                throw error;
            }

            const desktopId = textDecoder.decode(stdOut.toArray()).trim();
            const handlerApp = Shell.AppSystem.get_default().lookup_app(desktopId)?.appInfo;
            return handlerApp;
        } finally {
            subProcess.force_exit();
        }
    }

    getHandlerApp() {
        if (this._handlerApp)
            return this._handlerApp;

        if (!this.location)
            return null;

        const cancellable = new Utils.CancellableChild(this.cancellable);

        try {
            if (LocationAppInfo.GJS_BINARY_PATH)
                this._handlerApp = this._getHandlerAppFromWorker(cancellable);
            else
                this._handlerApp = this.location.query_default_handler(cancellable);

            if (!this._handlerApp) {
                throw new GLib.Error(Gio.IOErrorEnum,
                    Gio.IOErrorEnum.NOT_FOUND, `Handler for ${this.location} not found`);
            }
        } catch (e) {
            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_MOUNTED))
                return getFileManagerApp()?.appInfo;

            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
                logError(e, 'Impossible to find an URI handler for %s'.format(
                    this.get_id()));
            }

            throw e;
        }

        return this._handlerApp;
    }

    destroy() {
        this.location = null;
        this.icon = null;
        this.name = null;
        this._handlerApp = null;
        this.cancellable?.cancel();
    }
});

const MountableVolumeAppInfo = GObject.registerClass({
    Implements: [Gio.AppInfo],
    Properties: {
        'volume': GObject.ParamSpec.object(
            'volume', 'volume', 'volume',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
            Gio.Volume.$gtype),
        'mount': GObject.ParamSpec.object(
            'mount', 'mount', 'mount',
            GObject.ParamFlags.READWRITE,
            Gio.Mount.$gtype),
        'busy': GObject.ParamSpec.boolean(
            'busy', 'busy', 'busy',
            GObject.ParamFlags.READWRITE,
            false),
    },
},
class MountableVolumeAppInfo extends LocationAppInfo {
    _init(volume, cancellable = null) {
        super._init({
            volume,
            cancellable,
        });

        this._signalsHandler = new Utils.GlobalSignalsHandler();

        const updateAndMonitor = () => {
            this._update();
            this._monitorChanges();
        };
        updateAndMonitor();
        this._mountChanged = this.connect('notify::mount', updateAndMonitor);

        if (!this.mount && this.volume.get_identifier('class') === 'network') {
            // For some devices the mount point isn't advertised promptly
            // even if it's already existing, and there's no signaling about
            this._lazyUpdater = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => {
                this._update();
                delete this._lazyUpdater;
                return GLib.SOURCE_REMOVE;
            });
        }
    }

    get busy() {
        return !!this._currentAction;
    }

    get currentAction() {
        return this._currentAction;
    }

    destroy() {
        if (this._lazyUpdater) {
            GLib.source_remove(this._lazyUpdater);
            delete this._lazyUpdater;
        }
        this.disconnect(this._mountChanged);
        this.mount = null;
        this._signalsHandler.destroy();

        super.destroy();
    }

    vfunc_dup() {
        return new MountableVolumeAppInfo({
            volume: this.volume,
            cancellable: this.cancellable,
        });
    }

    vfunc_get_id() {
        const uuid = this.mount?.get_uuid() ?? this.volume.get_uuid();
        return uuid ? 'mountable-volume:%s'.format(uuid) : super.vfunc_get_id();
    }

    vfunc_equal(other) {
        if (this.volume === other?.volume && this.mount === other?.mount)
            return true;

        return this.get_id() === other?.get_id();
    }

    list_actions() {
        const actions = [];
        const {mount} = this;

        if (mount) {
            if (this.mount.can_unmount())
                actions.push('unmount');
            if (this.mount.can_eject())
                actions.push('eject');

            return actions;
        }

        if (this.volume.can_mount())
            actions.push('mount');
        if (this.volume.can_eject())
            actions.push('eject');

        return actions;
    }

    get_action_name(action) {
        switch (action) {
        case 'mount':
            return __('Mount');
        case 'unmount':
            return __('Unmount');
        case 'eject':
            return __('Eject');
        default:
            return null;
        }
    }

    vfunc_launch(files, context) {
        if (this.mount || files?.length)
            return super.vfunc_launch(files, context);

        this.mountAndLaunch(files, context);
        return true;
    }

    _update() {
        this.mount = this.volume.get_mount();

        const removable = this.mount ?? this.volume;
        this.name = removable.get_name();
        this.icon = removable.get_icon();

        this.location = this.mount?.get_default_location() ??
            this.volume.get_activation_root();

        this._updateLocationIcon({custom: true});
    }

    _monitorChanges() {
        this._signalsHandler.destroy();

        const removable = this.mount ?? this.volume;
        this._signalsHandler.add(removable, 'changed', () => this._update());

        if (this.mount) {
            this._signalsHandler.add(this.mount, 'pre-unmount', () => this._update());
            this._signalsHandler.add(this.mount, 'unmounted', () => this._update());
        }
    }

    async mountAndLaunch(files, context) {
        if (this.mount)
            return super.vfunc_launch(files, context);

        try {
            await this.launchAction('mount');
            if (!this.mount) {
                throw new Error('No mounted location to open for %s'.format(
                    this.get_id()));
            }

            return super.vfunc_launch(files, context);
        } catch (e) {
            logError(e, 'Mount and launch %s'.format(this.get_id()));
            return false;
        }
    }

    _notifyActionError(action, message) {
        if (action === 'mount') {
            global.notify_error(__('Failed to mount “%s”'.format(
                this.get_name())), message);
        } else if (action === 'unmount') {
            global.notify_error(__('Failed to umount “%s”'.format(
                this.get_name())), message);
        } else if (action === 'eject') {
            global.notify_error(__('Failed to eject “%s”'.format(
                this.get_name())), message);
        }
    }

    async launchAction(action) {
        if (!this.list_actions().includes(action))
            throw new Error('Action %s is not supported by %s', action, this);

        if (this._currentAction) {
            if (this._currentAction === 'mount') {
                this._notifyActionError(action,
                    __('Mount operation already in progress'));
            } else if (this._currentAction === 'unmount') {
                this._notifyActionError(action,
                    __('Umount operation already in progress'));
            } else if (this._currentAction === 'eject') {
                this._notifyActionError(action,
                    __('Eject operation already in progress'));
            }

            throw new Error('Another action %s is being performed in %s'.format(
                this._currentAction, this));
        }

        this._currentAction = action;
        this.notify('busy');
        const removable = this.mount ?? this.volume;
        const operation = new ShellMountOperation.ShellMountOperation(removable);
        try {
            if (action === 'mount') {
                await this.volume.mount(Gio.MountMountFlags.NONE, operation.mountOp,
                    this.cancellable);
            } else if (action === 'unmount') {
                await this.mount.unmount_with_operation(Gio.MountUnmountFlags.FORCE,
                    operation.mountOp, this.cancellable);
            } else if (action === 'eject') {
                await removable.eject_with_operation(Gio.MountUnmountFlags.FORCE,
                    operation.mountOp, this.cancellable);
            } else {
                logError(new Error(), 'No action %s on removable %s'.format(action,
                    removable.get_name()));
                return false;
            }

            return true;
        } catch (e) {
            if (action === 'mount' &&
                e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED))
                return true;
            else if (action === 'umount' &&
                     e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_MOUNTED))
                return true;

            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED))
                this._notifyActionError(action, e.message);

            if (action === 'mount' && this._isEncryptedMountError(e)) {
                delete this._currentAction;
                operation.close();
                return this.launchAction(action);
            }

            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
                logError(e, 'Impossible to %s removable %s'.format(action,
                    removable.get_name()));
            }

            return false;
        } finally {
            delete this._currentAction;
            this.notify('busy');
            this._update();
            operation.close();
        }
    }

    _isEncryptedMountError(error) {
        // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks
        // backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271

        if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED))
            return false;

        // cryptsetup
        if (error.message.includes('No key available with this passphrase'))
            return true;

        // udisks (no password)
        if (error.message.includes('No key available to unlock device'))
            return true;

        // libblockdev wrong password opening LUKS device
        if (error.message.includes('Failed to activate device: Incorrect passphrase'))
            return true;

        // cryptsetup returns EINVAL in many cases, including wrong TCRYPT password/parameters
        if (error.message.includes('Failed to load device\'s parameters: Invalid argument') ||
            error.message.includes(`Failed to load device's parameters: ${GLib.strerror(22 /* EINVAL */)}`))
            return true;

        // cryptsetup returns EPERM when the TCRYPT header can't be decrypted
        // with the provided password/parameters.
        if (error.message.includes('Failed to load device\'s parameters: Operation not permitted') ||
            error.message.includes(`Failed to load device's parameters: ${GLib.strerror(1 /* EPERM */)}`))
            return true;

        return false;
    }
});

const TrashAppInfo = GObject.registerClass({
    Implements: [Gio.AppInfo],
    Properties: {
        'empty': GObject.ParamSpec.boolean(
            'empty', 'empty', 'empty',
            GObject.ParamFlags.READWRITE,
            true),
    },
},
class TrashAppInfo extends LocationAppInfo {
    static initPromises(file) {
        if (TrashAppInfo._promisified)
            return;

        const trashProto = file.constructor.prototype;
        Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish');
        Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish');
        Gio._promisify(trashProto, 'enumerate_children_async', 'enumerate_children_finish');
        Gio._promisify(trashProto, 'query_info_async', 'query_info_finish');
        TrashAppInfo._promisified = true;
    }

    _init(cancellable = null) {
        super._init({
            location: Gio.file_new_for_uri(TRASH_URI),
            name: __('Trash'),
            icon: Gio.ThemedIcon.new(FALLBACK_TRASH_ICON),
            cancellable,
        });
        TrashAppInfo.initPromises(this.location);

        try {
            this._monitor = this.location.monitor_directory(0, this.cancellable);
            this._schedUpdateId = 0;
            this._monitorChangedId = this._monitor.connect('changed', () =>
                this._onTrashChange());
        } catch (e) {
            if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                return;
            logError(e, 'Impossible to monitor trash');
        }
        this._updateTrash();

        this.connect('notify::empty', () => this._updateLocationIcon());
        this.notify('empty');
    }

    destroy() {
        if (this._schedUpdateId) {
            GLib.source_remove(this._schedUpdateId);
            this._schedUpdateId = 0;
        }
        this._updateTrashCancellable?.cancel();
        this._monitor?.disconnect(this._monitorChangedId);
        this._monitor = null;

        super.destroy();
    }

    list_actions() {
        return this.empty ? [] : ['empty-trash'];
    }

    get_action_name(action) {
        switch (action) {
        case 'empty-trash':
            return __('Empty Trash');
        default:
            return null;
        }
    }

    _onTrashChange() {
        if (this._schedUpdateId) {
            GLib.source_remove(this._schedUpdateId);
            this._schedUpdateId = 0;
        }

        if (this._monitor.is_cancelled())
            return;

        this._schedUpdateId = GLib.timeout_add(GLib.PRIORITY_LOW,
            UPDATE_TRASH_DELAY, () => {
                this._schedUpdateId = 0;
                this._updateTrash();
                return GLib.SOURCE_REMOVE;
            });
    }

    async _updateTrash() {
        const priority = GLib.PRIORITY_LOW;
        this._updateTrashCancellable?.cancel();
        const cancellable = new Utils.CancellableChild(this.cancellable);
        this._updateTrashCancellable = cancellable;

        try {
            const trashInfo = await this.location.query_info_async(
                Gio.FILE_ATTRIBUTE_TRASH_ITEM_COUNT,
                Gio.FileQueryInfoFlags.NONE,
                priority, cancellable);
            this.empty = !trashInfo.get_attribute_uint32(
                Gio.FILE_ATTRIBUTE_TRASH_ITEM_COUNT);
            return;
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                logError(e, 'Impossible to get trash children from infos');
        } finally {
            cancellable.cancel();
            if (this._updateIconCancellable === cancellable)
                delete this._updateTrashCancellable;
        }

        try {
            const childrenEnumerator = await this.location.enumerate_children_async(
                Gio.FILE_ATTRIBUTE_STANDARD_TYPE, Gio.FileQueryInfoFlags.NONE,
                priority, cancellable);
            const children = await childrenEnumerator.next_files_async(1,
                priority, cancellable);
            this.empty = !children.length;

            await childrenEnumerator.close_async(priority, null);
        } catch (e) {
            if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
                logError(e, 'Impossible to enumerate trash children');
        } finally {
            cancellable.cancel();
            if (this._updateIconCancellable === cancellable)
                delete this._updateTrashCancellable;
        }
    }

    launchAction(action, timestamp) {
        if (!this.list_actions().includes(action))
            throw new Error('Action %s is not supported by %s', action, this);

        const nautilus = makeNautilusFileOperationsProxy();
        const askConfirmation = true;
        nautilus.EmptyTrashRemote(askConfirmation,
            nautilus.platformData({timestamp}), (_p, error) => {
                if (error)
                    logError(error, 'Empty trash failed');
            }, this.cancellable);
    }
});

/**
 * @param shellApp
 */
function wrapWindowsBackedApp(shellApp) {
    if (shellApp._dtdData)
        throw new Error('%s has been already wrapped'.format(shellApp));

    shellApp._dtdData = {
        windows: [],
        state: undefined,
        startingWorkspace: 0,
        isFocused: false,
        proxyProperties: [],
        sources: new Set(),
        signalConnections: new Utils.GlobalSignalsHandler(),
        methodInjections: new Utils.InjectionsHandler(),
        propertyInjections: new Utils.PropertyInjectionsHandler(),
        addProxyProperties(parent, proxyProperties) {
            Object.entries(proxyProperties).forEach(([p, o]) => {
                const publicProp = o.public ? p : `_${p}`;
                const get = o.getter && o.value instanceof Function
                    ? () => this[p]() : () => this[p];
                Object.defineProperty(parent, publicProp, Object.assign({
                    get,
                    set: v => (this[p] = v),
                    configurable: true,
                    enumerable: !!o.enumerable,
                }, o.readOnly ? {set: undefined} : {}));
                if (o.value)
                    this[p] = o.value;
                this.proxyProperties.push(publicProp);
            });
        },
        destroy() {
            this.windows = [];
            this.proxyProperties = [];
            this.sources.forEach(s => GLib.source_remove(s));
            this.sources.clear();
            this.signalConnections.destroy();
            this.methodInjections.destroy();
            this.propertyInjections.destroy();
        },
    };

    shellApp._dtdData.addProxyProperties(shellApp, {
        windows: {},
        state: {},
        startingWorkspace: {},
        isFocused: {public: true},
        signalConnections: {readOnly: true},
        sources: {readOnly: true},
        checkFocused: {},
        setDtdData: {},
    });

    shellApp._setDtdData = function (data, params = {}) {
        for (const [name, value] of Object.entries(data)) {
            if (params.readOnly && name in this._dtdData)
                throw new Error('Property %s is already defined'.format(name));
            const defaultParams = {public: true, readOnly: true};
            this._dtdData.addProxyProperties(this, {
                [name]: {...defaultParams, ...params, value},
            });
        }
    };

    const m = (...args) => shellApp._dtdData.methodInjections.add(shellApp, ...args);
    const p = (...args) => shellApp._dtdData.propertyInjections.add(shellApp, ...args);

    // mi is Method injector, pi is Property injector
    shellApp._setDtdData({mi: m, pi: p}, {public: false});

    m('get_state', () => shellApp._state ?? shellApp._getStateByWindows());
    p('state', {get: () => shellApp.get_state()});

    m('get_windows', () => shellApp._windows);
    m('get_n_windows', () => shellApp._windows.length);
    m('get_pids', () => shellApp._windows.reduce((pids, w) => {
        if (w.get_pid() > 0 && !pids.includes(w.get_pid()))
            pids.push(w.get_pid());
        return pids;
    }, []));
    m('is_on_workspace', (_om, workspace) => shellApp._windows.some(w =>
        w.get_workspace() === workspace) ||
        (shellApp.state === Shell.AppState.STARTING &&
         [-1, workspace.index()].includes(shellApp._startingWorkspace)));
    m('request_quit', () => shellApp._windows.filter(w =>
        w.can_close()).forEach(w => w.delete(global.get_current_time())));

    shellApp._setDtdData({
        _getStateByWindows() {
            return this.get_n_windows() ? Shell.AppState.RUNNING : Shell.AppState.STOPPED;
        },

        _updateWindows() {
            throw new GObject.NotImplementedError(`_updateWindows in ${this.constructor.name}`);
        },

        _notifyStateChanged() {
            Shell.AppSystem.get_default().emit('app-state-changed', this);
            this.notify('state');
        },

        _setState(state) {
            const oldState = this.state;
            this._state = state;

            if (this.state !== oldState)
                this._notifyStateChanged();
        },

        _setWindows(windows) {
            const oldState = this.state;
            const oldWindows = this._windows.slice();
            const result = {windowsChanged: false, stateChanged: false};
            this._state = undefined;

            if (windows.length !== oldWindows.length ||
                windows.some((win, index) => win !== oldWindows[index])) {
                this._windows = windows.filter(w => !w.is_override_redirect());
                this.emit('windows-changed');
                result.windowsChanged = true;
            }

            if (this.state !== oldState) {
                this._notifyStateChanged();
                this._checkFocused();
                result.stateChanged = true;
            }

            return result;
        },
    }, {readOnly: false});

    shellApp._sources.add(GLib.idle_add(GLib.DEFAULT_PRIORITY, () => {
        shellApp._updateWindows();
        shellApp._sources.delete(GLib.main_current_source().source_id);
        return GLib.SOURCE_REMOVE;
    }));

    const windowTracker = Shell.WindowTracker.get_default();
    shellApp._checkFocused = function () {
        if (this._windows.some(w => w.has_focus())) {
            this.isFocused = true;
            windowTracker.notify('focus-app');
        } else if (this.isFocused) {
            this.isFocused = false;
            windowTracker.notify('focus-app');
        }
    };

    shellApp._checkFocused();
    shellApp._signalConnections.add(global.display, 'notify::focus-window', () =>
        shellApp._checkFocused());

    // Re-implements shell_app_activate_window for generic activation and alt-tab support
    m('activate_window', function (_om, window, timestamp) {
        /* eslint-disable no-invalid-this */
        if (!window)
            [window] = this.get_windows();
        else if (!this._windows.includes(window))
            return;

        const currentWorkspace = global.workspace_manager.get_active_workspace();
        const workspace = window.get_workspace();
        const sameWorkspaceWindows = this.get_windows().filter(w =>
            w.get_workspace() === workspace);
        sameWorkspaceWindows.forEach(w => w.raise());

        if (workspace !== currentWorkspace)
            workspace.activate_with_focus(window, timestamp);
        else
            window.activate(timestamp);
        /* eslint-enable no-invalid-this */
    });

    // Re-implements shell_app_activate_full for generic activation and dash support
    m('activate_full', function (_om, workspace, timestamp) {
        /* eslint-disable no-invalid-this */
        if (!timestamp)
            timestamp = global.get_current_time();

        switch (this.state) {
        case Shell.AppState.STOPPED:
            try {
                this._startingWorkspace = workspace;
                this._setState(Shell.AppState.STARTING);
                this.launch(timestamp, workspace, Shell.AppLaunchGpu.APP_PREF);
            } catch (e) {
                logError(e);
                this._setState(Shell.AppState.STOPPED);
                global.notify_error(_('Failed to launch “%s”'.format(
                    this.get_name())), e.message);
            }
            break;
        case Shell.AppState.RUNNING:
            this.activate_window(null, timestamp);
            break;
        }
        /* eslint-enable no-invalid-this */
    });

    m('activate', () => shellApp.activate_full(-1, 0));

    m('compare', (_om, other) => Utils.shellAppCompare(shellApp, other));

    const {destroy: defaultDestroy} = shellApp;
    shellApp.destroy = function () {
        /* eslint-disable no-invalid-this */
        this._dtdData.proxyProperties.forEach(prop => delete this[prop]);
        this._dtdData.destroy();
        this._dtdData = undefined;
        this.appInfo.destroy?.();
        this.destroy = defaultDestroy;
        defaultDestroy?.call(this);
        /* eslint-enable no-invalid-this */
    };

    return shellApp;
}

/**
 * We can't inherit from Shell.App as it's a final type, so let's patch it
 *
 * @param params
 */
function makeLocationApp(params) {
    if (!(params?.appInfo instanceof LocationAppInfo))
        throw new TypeError('Invalid location');

    const {fallbackIconName} = params;
    delete params.fallbackIconName;

    const shellApp = new Shell.App(params);
    wrapWindowsBackedApp(shellApp);

    shellApp._setDtdData({
        location: () => shellApp.appInfo.location,
        isTrash: shellApp.appInfo instanceof TrashAppInfo,
    }, {getter: true, enumerable: true});

    shellApp._mi('toString', defaultToString =>
        '[LocationApp "%s" - %s]'.format(shellApp.get_id(),
            defaultToString.call(shellApp)));

    shellApp._mi('launch', (_om, timestamp, workspace, _gpuPref) =>
        shellApp.appInfo.launch([],
            global.create_app_launch_context(timestamp, workspace)));

    shellApp._mi('launch_action', (_om, actionName, ...args) =>
        shellApp.appInfo.launchAction(actionName, ...args));

    shellApp._mi('create_icon_texture', (_om, iconSize) => new St.Icon({
        iconSize,
        gicon: shellApp.icon,
        fallbackIconName,
    }));

    // FIXME: We need to add a new API to Nautilus to open new windows
    shellApp._mi('can_open_new_window', () => {
        try {
            if (!shellApp.get_n_windows())
                return true;

            const handlerApp = shellApp.appInfo.getHandlerApp();

            if (handlerApp.has_key('SingleMainWindow'))
                return !handlerApp.get_boolean('SingleMainWindow');

            if (handlerApp.has_key('X-GNOME-SingleWindow'))
                return !handlerApp.get_boolean('X-GNOME-SingleWindow');

            if (handlerApp.get_commandline()?.split(' ').includes('--new-window'))
                return true;

            const [window] = shellApp.get_windows();
            if (window && window.get_gtk_window_object_path())
                return window.get_gtk_application_id() === null;

            return true;
        } catch {
            return false;
        }
    });

    shellApp._mi('open_new_window', function (_om, workspace) {
        /* eslint-disable no-invalid-this */
        const context = global.create_app_launch_context(0, workspace);
        if (!this.get_n_windows()) {
            this.appInfo.launch([], context);
            return;
        }
        const appId = this.appInfo.get_id();
        Gio.AppInfo.create_from_commandline(this.appInfo.get_commandline(),
            this.appInfo.get_id(), appId
                ? Gio.AppInfoCreateFlags.SUPPORTS_STARTUP_NOTIFICATION
                : Gio.AppInfoCreateFlags.NONE).launch_uris(
            [this.appInfo.location.get_uri()], context);
        /* eslint-enable no-invalid-this */
    });

    if (shellApp.appInfo instanceof MountableVolumeAppInfo) {
        shellApp._mi('get_busy', function (parentGetBusy) {
            /* eslint-disable no-invalid-this */
            if (this.appInfo.busy)
                return true;
            return parentGetBusy.call(this);
            /* eslint-enable no-invalid-this */
        });
        shellApp._pi('busy', {get: () => shellApp.get_busy()});
        shellApp._signalConnections.add(shellApp.appInfo, 'notify::busy', _ =>
            shellApp.notify('busy'));
    }

    shellApp._mi('get_windows', function () {
        /* eslint-disable no-invalid-this */
        if (this._needsResort)
            this._sortWindows();
        return this._windows;
        /* eslint-enable no-invalid-this */
    });

    const {fm1Client} = Docking.DockManager.getDefault();
    shellApp._setDtdData({
        _needsResort: true,

        _windowsOrderChanged() {
            this._needsResort = true;
            this.emit('windows-changed');
        },

        _sortWindows() {
            this._windows.sort(Utils.shellWindowsCompare);
            this._needsResort = false;
        },

        _updateWindows() {
            const windows = fm1Client.getWindows(this.location?.get_uri()).sort(
                Utils.shellWindowsCompare);
            const {windowsChanged} = this._setWindows(windows);

            if (!windowsChanged)
                return;

            this._signalConnections.removeWithLabel(Labels.LOCATION_WINDOWS);
            windows.forEach(w =>
                this._signalConnections.addWithLabel(Labels.LOCATION_WINDOWS, w,
                    'notify::user-time', () => {
                        if (w !== this._windows[0])
                            this._windowsOrderChanged();
                    }));
        },
    }, {readOnly: false});

    shellApp._signalConnections.add(fm1Client, 'windows-changed', () =>
        shellApp._updateWindows());
    shellApp._signalConnections.add(shellApp.appInfo, 'notify::icon', () =>
        shellApp.notify('icon'));
    shellApp._signalConnections.add(global.workspaceManager,
        'workspace-switched', () => shellApp._windowsOrderChanged());

    return shellApp;
}

/**
 *
 */
function getFileManagerApp() {
    return Shell.AppSystem.get_default().lookup_app(FILE_MANAGER_DESKTOP_APP_ID);
}

/**
 *
 */
export function wrapFileManagerApp() {
    const fileManagerApp = getFileManagerApp();
    if (!fileManagerApp)
        return null;

    if (fileManagerApp._dtdData)
        return fileManagerApp;

    const originalGetWindows = fileManagerApp.get_windows;
    wrapWindowsBackedApp(fileManagerApp);

    const {removables, trash} = Docking.DockManager.getDefault();
    fileManagerApp._signalConnections.addWithLabel(Labels.WINDOWS_CHANGED,
        fileManagerApp, 'windows-changed', () => {
            fileManagerApp.stop_emission_by_name('windows-changed');
            // Let's wait for the location app to take control before of us
            const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
                fileManagerApp._sources.delete(id);
                fileManagerApp._updateWindows();
                return GLib.SOURCE_REMOVE;
            });
            fileManagerApp._sources.add(id);
        });

    fileManagerApp._signalConnections.add(global.workspaceManager,
        'workspace-switched', () => {
            fileManagerApp._signalConnections.blockWithLabel(Labels.WINDOWS_CHANGED);
            fileManagerApp.emit('windows-changed');
            fileManagerApp._signalConnections.unblockWithLabel(Labels.WINDOWS_CHANGED);
        });

    if (removables) {
        fileManagerApp._signalConnections.add(removables, 'changed', () =>
            fileManagerApp._updateWindows());
        fileManagerApp._signalConnections.add(removables, 'windows-changed', () =>
            fileManagerApp._updateWindows());
    }

    if (trash?.getApp()) {
        fileManagerApp._signalConnections.add(trash.getApp(), 'windows-changed', () =>
            fileManagerApp._updateWindows());
    }

    fileManagerApp._updateWindows = function () {
        const locationWindows = [];
        getRunningApps().forEach(a => locationWindows.push(...a.get_windows()));
        const windows = originalGetWindows.call(this).filter(w =>
            !locationWindows.includes(w));

        this._signalConnections.blockWithLabel(Labels.WINDOWS_CHANGED);
        this._setWindows(windows);
        this._signalConnections.unblockWithLabel(Labels.WINDOWS_CHANGED);
    };

    fileManagerApp._mi('toString', defaultToString =>
        '[FileManagerApp - %s]'.format(defaultToString.call(fileManagerApp)));

    return fileManagerApp;
}

/**
 *
 */
export function unWrapFileManagerApp() {
    const fileManagerApp = getFileManagerApp();
    if (!fileManagerApp || !fileManagerApp._dtdData)
        return;

    fileManagerApp.destroy();
}

/**
 * This class maintains a Shell.App representing the Trash and keeps it
 * up-to-date as the trash fills and is emptied over time.
 */
export class Trash {
    destroy() {
        this._trashApp?.destroy();
    }

    _ensureApp() {
        if (this._trashApp)
            return;

        this._trashApp = makeLocationApp({
            appInfo: new TrashAppInfo(new Gio.Cancellable()),
            fallbackIconName: FALLBACK_TRASH_ICON,
        });
    }

    getApp() {
        this._ensureApp();
        return this._trashApp;
    }
}

/**
 * This class maintains Shell.App representations for removable devices
 * plugged into the system, and keeps the list of Apps up-to-date as
 * devices come and go and are mounted and unmounted.
 */
export class Removables {
    static initVolumePromises(object) {
        // TODO: This can be simplified using actual interface type when we
        // can depend on gjs 1.72
        if (!(object instanceof Gio.Volume) || object.constructor.prototype._d2dPromisified)
            return;

        Gio._promisify(object.constructor.prototype, 'mount', 'mount_finish');
        Gio._promisify(object.constructor.prototype, 'eject_with_operation',
            'eject_with_operation_finish');
        object.constructor.prototype._d2dPromisified = true;
    }

    static initMountPromises(object) {
        // TODO: This can be simplified using actual interface type when we
        // can depend on gjs 1.72
        if (!(object instanceof Gio.Mount) || object.constructor.prototype._d2dPromisified)
            return;

        Gio._promisify(object.constructor.prototype, 'eject_with_operation',
            'eject_with_operation_finish');
        Gio._promisify(object.constructor.prototype, 'unmount_with_operation',
            'unmount_with_operation_finish');
        object.constructor.prototype._d2dPromisified = true;
    }

    constructor() {
        this._signalsHandler = new Utils.GlobalSignalsHandler();

        this._monitor = Gio.VolumeMonitor.get();
        this._cancellable = new Gio.Cancellable();

        this._monitor.get_mounts().forEach(m => Removables.initMountPromises(m));
        this._updateVolumes();

        this._signalsHandler.add([
            this._monitor,
            'volume-added',
            (_, volume) => this._onVolumeAdded(volume),
        ], [
            this._monitor,
            'volume-removed',
            (_, volume) => this._onVolumeRemoved(volume),
        ], [
            this._monitor,
            'mount-added',
            (_, mount) => this._onMountAdded(mount),
        ], [
            Docking.DockManager.settings,
            'changed::show-mounts-only-mounted',
            () => this._updateVolumes(),
        ], [
            Docking.DockManager.settings,
            'changed::show-mounts-network',
            () => this._updateVolumes(),
        ]);
    }

    destroy() {
        this._volumeApps.forEach(a => a.destroy());
        this._volumeApps = [];
        this._cancellable.cancel();
        this._cancellable = null;
        this._signalsHandler.destroy();
        this._monitor = null;
    }

    _updateVolumes() {
        this._volumeApps?.forEach(a => a.destroy());
        this._volumeApps = [];
        this.emit('changed');

        this._monitor.get_volumes().forEach(v => this._onVolumeAdded(v));
    }

    _onVolumeAdded(volume) {
        Removables.initVolumePromises(volume);

        if (!Docking.DockManager.settings.showMountsNetwork &&
            volume.get_identifier('class') === 'network')
            return;


        const mount = volume.get_mount();
        if (mount) {
            if (mount.is_shadowed())
                return;
            if (!mount.can_eject() && !mount.can_unmount())
                return;
        } else {
            if (Docking.DockManager.settings.showMountsOnlyMounted)
                return;
            if (!volume.can_mount() && !volume.can_eject())
                return;
        }

        const appInfo = new MountableVolumeAppInfo(volume,
            new Utils.CancellableChild(this._cancellable));
        const volumeApp = makeLocationApp({
            appInfo,
            fallbackIconName: FALLBACK_REMOVABLE_MEDIA_ICON,
        });

        volumeApp._signalConnections.add(volumeApp, 'windows-changed',
            () => this.emit('windows-changed', volumeApp));

        if (Docking.DockManager.settings.showMountsOnlyMounted) {
            volumeApp._signalConnections.add(appInfo, 'notify::mount',
                () => !appInfo.mount && this._onVolumeRemoved(appInfo.volume));
        }

        this._volumeApps.push(volumeApp);
        this.emit('changed');
    }

    _onVolumeRemoved(volume) {
        const volumeIndex = this._volumeApps.findIndex(({appInfo}) =>
            appInfo.volume === volume);
        if (volumeIndex !== -1) {
            const [volumeApp] = this._volumeApps.splice(volumeIndex, 1);
            // We don't care about cancelling the ongoing operations from now on.
            volumeApp.appInfo.cancellable = null;
            volumeApp.destroy();
            this.emit('changed');
        }
    }

    _onMountAdded(mount) {
        Removables.initMountPromises(mount);

        if (!Docking.DockManager.settings.showMountsOnlyMounted)
            return;

        if (!this._volumeApps.find(({appInfo}) => appInfo.mount === mount)) {
            // In some Gio.Mount implementations the volume may be set after
            // mount is emitted, so we could just ignore it as we'll get it
            // later via volume-added
            const volume = mount.get_volume();
            if (volume)
                this._onVolumeAdded(volume);
        }
    }

    getApps() {
        return this._volumeApps;
    }
}
Signals.addSignalMethods(Removables.prototype);

/**
 *
 */
function getApps() {
    const dockManager = Docking.DockManager.getDefault();
    const locationApps = [];

    if (dockManager.removables)
        locationApps.push(...dockManager.removables.getApps());

    if (dockManager.trash)
        locationApps.push(dockManager.trash.getApp());

    return locationApps;
}

/**
 *
 */
export function getRunningApps() {
    return getApps().filter(a => a.state === Shell.AppState.RUNNING);
}

/**
 *
 */
export function getStartingApps() {
    return getApps().filter(a => a.state === Shell.AppState.STARTING);
}
