import Adw from 'gi://Adw?version=1';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk?version=4.0';
import Shew from 'gi://Shew';

import * as Gettext from 'gettext';
const Package = imports.package;

Package.initFormat();

import * as Config from './misc/config.js';
import * as ExtensionUtils from './misc/extensionUtils.js';

import {ExtensionState, ExtensionType}  from './misc/extensionUtils.js';

const GnomeShellIface = loadInterfaceXML('org.gnome.Shell.Extensions');
const GnomeShellProxy = Gio.DBusProxy.makeProxyWrapper(GnomeShellIface);

Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Shew.WindowExporter.prototype, 'export');

function loadInterfaceXML(iface) {
    const uri = `resource:///org/gnome/Extensions/dbus-interfaces/${iface}.xml`;
    const f = Gio.File.new_for_uri(uri);

    try {
        let [ok_, bytes] = f.load_contents(null);
        return new TextDecoder().decode(bytes);
    } catch (e) {
        log(`Failed to load D-Bus interface ${iface}`);
    }

    return null;
}

function toggleState(action) {
    let state = action.get_state();
    action.change_state(new GLib.Variant('b', !state.get_boolean()));
}

var Application = GObject.registerClass(
class Application extends Adw.Application {
    _init() {
        GLib.set_prgname('gnome-extensions-app');
        super._init({application_id: Package.name});

        this.connect('window-removed', (a, window) => window.run_dispose());
    }

    get shellProxy() {
        return this._shellProxy;
    }

    vfunc_activate() {
        this._shellProxy.CheckForUpdatesAsync().catch(logError);
        this._window.present();
    }

    vfunc_startup() {
        super.vfunc_startup();

        this.add_action_entries(
            [{
                name: 'quit',
                activate: () => this._window.close(),
            }]);

        this.set_accels_for_action('app.quit', ['<Primary>q']);

        this._shellProxy = new GnomeShellProxy(Gio.DBus.session,
            'org.gnome.Shell.Extensions', '/org/gnome/Shell/Extensions');

        this._window = new ExtensionsWindow({application: this});
    }
});

var ExtensionsWindow = GObject.registerClass({
    GTypeName: 'ExtensionsWindow',
    Template: 'resource:///org/gnome/Extensions/ui/extensions-window.ui',
    InternalChildren: [
        'userGroup',
        'userList',
        'systemGroup',
        'systemList',
        'mainStack',
        'searchBar',
        'searchButton',
        'searchEntry',
        'updatesBar',
        'updatesLabel',
    ],
}, class ExtensionsWindow extends Adw.ApplicationWindow {
    _init(params) {
        super._init(params);

        if (Config.PROFILE === 'development')
            this.add_css_class('devel');

        this._updatesCheckId = 0;

        this._exporter = new Shew.WindowExporter({window: this});
        this._exportedHandle = '';

        this.add_action_entries(
            [{
                name: 'show-about',
                activate: () => this._showAbout(),
            }, {
                name: 'logout',
                activate: () => this._logout(),
            }, {
                name: 'user-extensions-enabled',
                state: 'false',
                change_state: (a, state) => {
                    this._shellProxy.UserExtensionsEnabled = state.get_boolean();
                },
            }]);

        this._searchTerms = [];
        this._searchEntry.connect('search-changed', () => {
            const {text} = this._searchEntry;
            if (text === '')
                this._searchTerms = [];
            else
                [this._searchTerms] = GLib.str_tokenize_and_fold(text, null);

            this._userList.invalidate_filter();
            this._systemList.invalidate_filter();
        });

        this._userList.set_sort_func(this._sortList.bind(this));
        this._userList.set_filter_func(this._filterList.bind(this));
        this._userList.set_placeholder(new Gtk.Label({
            label: _('No Matches'),
            margin_start: 12,
            margin_end: 12,
            margin_top: 12,
            margin_bottom: 12,
        }));
        this._userList.connect('row-activated', (_list, row) => row.activate());

        this._systemList.set_sort_func(this._sortList.bind(this));
        this._systemList.set_filter_func(this._filterList.bind(this));
        this._systemList.set_placeholder(new Gtk.Label({
            label: _('No Matches'),
            margin_start: 12,
            margin_end: 12,
            margin_top: 12,
            margin_bottom: 12,
        }));
        this._systemList.connect('row-activated', (_list, row) => row.activate());

        this._shellProxy.connectSignal('ExtensionStateChanged',
            this._onExtensionStateChanged.bind(this));

        this._shellProxy.connect('g-properties-changed',
            this._onUserExtensionsEnabledChanged.bind(this));
        this._onUserExtensionsEnabledChanged();

        this._scanExtensions();
    }

    get _shellProxy() {
        return this.application.shellProxy;
    }

    uninstall(uuid) {
        let row = this._findExtensionRow(uuid);

        let dialog = new Gtk.MessageDialog({
            transient_for: this,
            modal: true,
            text: _('Remove “%s”?').format(row.name),
            secondary_text: _('If you remove the extension, you need to return to download it if you want to enable it again'),
        });

        dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
        dialog.add_button(_('Remove'), Gtk.ResponseType.ACCEPT)
            .get_style_context().add_class('destructive-action');

        dialog.connect('response', (dlg, response) => {
            if (response === Gtk.ResponseType.ACCEPT)
                this._shellProxy.UninstallExtensionAsync(uuid).catch(logError);
            dialog.destroy();
        });
        dialog.present();
    }

    async openPrefs(uuid) {
        if (!this._exportedHandle) {
            try {
                this._exportedHandle = await this._exporter.export();
            } catch (e) {
                log(`Failed to export window: ${e.message}`);
            }
        }

        this._shellProxy.OpenExtensionPrefsAsync(uuid,
            this._exportedHandle,
            {modal: new GLib.Variant('b', true)}).catch(logError);
    }

    _showAbout() {
        let aboutWindow = new Adw.AboutWindow({
            developers: [
                'Florian Müllner <fmuellner@gnome.org>',
                'Jasper St. Pierre <jstpierre@mecheye.net>',
                'Didier Roche <didrocks@ubuntu.com>',
                'Romain Vigier <contact@romainvigier.fr>',
            ],
            designers: [
                'Allan Day <allanpday@gmail.com>',
                'Tobias Bernard <tbernard@gnome.org>',
            ],
            translator_credits: _('translator-credits'),
            application_name: _('Extensions'),
            license_type: Gtk.License.GPL_2_0,
            application_icon: Package.name,
            version: Package.version,
            developer_name: _('The GNOME Project'),
            website: 'https://apps.gnome.org/app/org.gnome.Extensions/',
            issue_url: 'https://gitlab.gnome.org/GNOME/gnome-shell/issues/new',

            transient_for: this,
        });
        aboutWindow.present();
    }

    _logout() {
        this.application.get_dbus_connection().call(
            'org.gnome.SessionManager',
            '/org/gnome/SessionManager',
            'org.gnome.SessionManager',
            'Logout',
            new GLib.Variant('(u)', [0]),
            null,
            Gio.DBusCallFlags.NONE,
            -1,
            null);
    }

    _sortList(row1, row2) {
        return row1.name.localeCompare(row2.name);
    }

    _filterList(row) {
        return this._searchTerms.every(
            t => row.keywords.some(k => k.startsWith(t)));
    }

    _findExtensionRow(uuid) {
        return [
            ...this._userList,
            ...this._systemList,
        ].find(c => c.uuid === uuid);
    }

    _onUserExtensionsEnabledChanged() {
        let action = this.lookup_action('user-extensions-enabled');
        action.set_state(
            new GLib.Variant('b', this._shellProxy.UserExtensionsEnabled));
    }

    _onExtensionStateChanged(proxy, senderName, [uuid, newState]) {
        let extension = ExtensionUtils.deserializeExtension(newState);
        let row = this._findExtensionRow(uuid);

        this._queueUpdatesCheck();

        // the extension's type changed; remove the corresponding row
        // and reset the variable to null so that we create a new row
        // below and add it to the appropriate list
        if (row && row.type !== extension.type) {
            row.get_parent().remove(row);
            row = null;
        }

        if (row) {
            if (extension.state === ExtensionState.UNINSTALLED)
                row.get_parent().remove(row);
        } else {
            this._addExtensionRow(extension);
        }

        this._syncListVisibility();
    }

    async _scanExtensions() {
        try {
            const [extensionsMap] = await this._shellProxy.ListExtensionsAsync();

            for (let uuid in extensionsMap) {
                let extension = ExtensionUtils.deserializeExtension(extensionsMap[uuid]);
                this._addExtensionRow(extension);
            }
            this._extensionsLoaded();
        } catch (e) {
            if (e instanceof Gio.DBusError) {
                log(`Failed to connect to shell proxy: ${e}`);
                this._mainStack.visible_child_name = 'noshell';
            } else {
                throw e;
            }
        }
    }

    _addExtensionRow(extension) {
        let row = new ExtensionRow(extension);

        if (row.type === ExtensionType.PER_USER)
            this._userList.append(row);
        else
            this._systemList.append(row);
    }

    _queueUpdatesCheck() {
        if (this._updatesCheckId)
            return;

        this._updatesCheckId = GLib.timeout_add_seconds(
            GLib.PRIORITY_DEFAULT, 1, () => {
                this._checkUpdates();

                this._updatesCheckId = 0;
                return GLib.SOURCE_REMOVE;
            });
    }

    _syncListVisibility() {
        this._userGroup.visible = [...this._userList].length > 1;
        this._systemGroup.visible = [...this._systemList].length > 1;

        if (this._userGroup.visible || this._systemGroup.visible)
            this._mainStack.visible_child_name = 'main';
        else
            this._mainStack.visible_child_name = 'placeholder';
    }

    _checkUpdates() {
        let nUpdates = [...this._userList].filter(c => c.hasUpdate).length;

        this._updatesLabel.label = Gettext.ngettext(
            '%d extension will be updated on next login.',
            '%d extensions will be updated on next login.',
            nUpdates).format(nUpdates);
        this._updatesBar.revealed = nUpdates > 0;
    }

    _extensionsLoaded() {
        this._syncListVisibility();
        this._checkUpdates();
    }
});

var ExtensionRow = GObject.registerClass({
    GTypeName: 'ExtensionRow',
    Template: 'resource:///org/gnome/Extensions/ui/extension-row.ui',
    InternalChildren: [
        'nameLabel',
        'descriptionLabel',
        'versionLabel',
        'errorLabel',
        'errorIcon',
        'updatesIcon',
        'switch',
        'actionsBox',
    ],
}, class ExtensionRow extends Gtk.ListBoxRow {
    _init(extension) {
        super._init();

        this._app = Gio.Application.get_default();
        this._extension = extension;
        this._prefsModule = null;

        [this._keywords] = GLib.str_tokenize_and_fold(this.name, null);

        this._actionGroup = new Gio.SimpleActionGroup();
        this.insert_action_group('row', this._actionGroup);

        let action;
        action = new Gio.SimpleAction({
            name: 'show-prefs',
            enabled: this.hasPrefs,
        });
        action.connect('activate', () => this.get_root().openPrefs(this.uuid));
        this._actionGroup.add_action(action);

        action = new Gio.SimpleAction({
            name: 'show-url',
            enabled: this.url !== '',
        });
        action.connect('activate', () => {
            Gio.AppInfo.launch_default_for_uri(
                this.url, this.get_display().get_app_launch_context());
        });
        this._actionGroup.add_action(action);

        action = new Gio.SimpleAction({
            name: 'uninstall',
            enabled: this.type === ExtensionType.PER_USER,
        });
        action.connect('activate', () => this.get_root().uninstall(this.uuid));
        this._actionGroup.add_action(action);

        action = new Gio.SimpleAction({
            name: 'enabled',
            state: new GLib.Variant('b', false),
        });
        action.connect('activate', toggleState);
        action.connect('change-state', (a, state) => {
            if (state.get_boolean())
                this._app.shellProxy.EnableExtensionAsync(this.uuid).catch(logError);
            else
                this._app.shellProxy.DisableExtensionAsync(this.uuid).catch(logError);
        });
        this._actionGroup.add_action(action);

        this._nameLabel.label = this.name;

        const desc = this._extension.metadata.description.split('\n')[0];
        this._descriptionLabel.label = desc;
        this._descriptionLabel.tooltip_text = desc;

        this.connect('destroy', this._onDestroy.bind(this));

        this._extensionStateChangedId = this._app.shellProxy.connectSignal(
            'ExtensionStateChanged', (p, sender, [uuid, newState]) => {
                if (this.uuid !== uuid)
                    return;

                this._extension = ExtensionUtils.deserializeExtension(newState);
                this._updateState();
            });
        this._updateState();
    }

    vfunc_activate() {
        this._switch.mnemonic_activate(false);
    }

    get uuid() {
        return this._extension.uuid;
    }

    get name() {
        return this._extension.metadata.name;
    }

    get hasPrefs() {
        return this._extension.hasPrefs;
    }

    get hasUpdate() {
        return this._extension.hasUpdate || false;
    }

    get hasError() {
        const {state} = this._extension;
        return state === ExtensionState.OUT_OF_DATE ||
               state === ExtensionState.ERROR;
    }

    get type() {
        return this._extension.type;
    }

    get creator() {
        return this._extension.metadata.creator || '';
    }

    get url() {
        return this._extension.metadata.url || '';
    }

    get version() {
        return this._extension.metadata.version || '';
    }

    get error() {
        if (!this.hasError)
            return '';

        if (this._extension.state === ExtensionState.OUT_OF_DATE)
            return _('The extension is incompatible with the current GNOME version');

        return this._extension.error
            ? this._extension.error : _('The extension had an error');
    }

    get keywords() {
        return this._keywords;
    }

    _updateState() {
        let state = this._extension.state === ExtensionState.ENABLED;

        let action = this._actionGroup.lookup('enabled');
        action.set_state(new GLib.Variant('b', state));
        action.enabled = this._canToggle();

        if (!action.enabled)
            this._switch.active = state;

        this._updatesIcon.visible = this.hasUpdate;
        this._errorIcon.visible = this.hasError;

        this._descriptionLabel.visible = !this.hasError;

        this._errorLabel.label = this.error;
        this._errorLabel.visible = this.error !== '';

        this._versionLabel.label = this.version.toString();
        this._versionLabel.visible = this.version !== '';
    }

    _onDestroy() {
        if (!this._app.shellProxy)
            return;

        if (this._extensionStateChangedId)
            this._app.shellProxy.disconnectSignal(this._extensionStateChangedId);
        this._extensionStateChangedId = 0;
    }

    _canToggle() {
        return this._extension.canChange;
    }
});

function initEnvironment() {
    // Monkey-patch in a "global" object that fakes some Shell utilities
    // that ExtensionUtils depends on.
    globalThis.global = {
        log(...args) {
            print(args.join(', '));
        },

        logError(s) {
            log(`ERROR: ${s}`);
        },

        userdatadir: GLib.build_filenamev([GLib.get_user_data_dir(), 'gnome-shell']),
    };
}

/**
 * Main entrypoint for the app
 *
 * @param {string[]} argv - command line arguments
 * @returns {void}
 */
export async function main(argv) {
    initEnvironment();
    Package.initGettext();

    await new Application().runAsync(argv);
}