// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
/*
 * Copyright 2010 Red Hat, Inc
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2, or (at your option)
 * any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street - Suite 500, Boston, MA
 * 02110-1335, USA.
*
 * Author: David Zeuthen <davidz@redhat.com>
 */

const Signals = imports.signals;
const Cinnamon = imports.gi.Cinnamon;
const AccountsService = imports.gi.AccountsService;
const Clutter = imports.gi.Clutter;
const St = imports.gi.St;
const Pango = imports.gi.Pango;
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const GLib = imports.gi.GLib;
const Mainloop = imports.mainloop;
const Polkit = imports.gi.Polkit;
const PolkitAgent = imports.gi.PolkitAgent;
const GnomeSession = imports.misc.gnomeSession;
const Meta = imports.gi.Meta;

const Dialog = imports.ui.dialog;
const Main = imports.ui.main;
const ModalDialog = imports.ui.modalDialog;
const CinnamonEntry = imports.ui.cinnamonEntry;
const PopupMenu = imports.ui.popupMenu;
const UserWidget = imports.ui.userWidget;
const Util = imports.misc.util;

const DIALOG_ICON_SIZE = 64;
const DELAYED_RESET_TIMEOUT = 200;

const MAX_MODAL_RETRIES = 2;

var RootUser = class {
    constructor(name) {
        this.userName = name;
        this.realName = _("Superuser (%s)").format(name);

        this.avatar = new St.Icon({
            icon_name: 'xsi-avatar-default-symbolic',
            icon_type: St.IconType.SYMBOLIC,
            icon_size: DIALOG_ICON_SIZE,
            style_class: 'user-icon'
        });
        this.avatar.x_align = Clutter.ActorAlign.CENTER;
    }
    destroy() {}
};

var AdminUser = class {
    constructor(user) {
        this._user = user;
        this._userName = null;
        this._realName = null;
        this._avatar = null;

        this._avatar = new UserWidget.Avatar(this._user, {
            iconSize: DIALOG_ICON_SIZE,
        });
        this._avatar.x_align = Clutter.ActorAlign.CENTER;
        this._avatar.visible = false;

        this._userLoadedId = this._user.connect('notify::is-loaded',
            this._onUserChanged.bind(this));
        this._userChangedId = this._user.connect('changed',
            this._onUserChanged.bind(this));
        this._onUserChanged();
    }

    get avatar() {
        return this._avatar;
    }

    get realName() {
        return this._realName;
    }

    get userName() {
        return this._userName;
    }

    _onUserChanged() {
        if (this._user.is_loaded && this._avatar) {
            this._userName = this._user.get_user_name();
            this._realName = this._user.get_real_name();
            this._avatar.update();
        }
    }

    destroy() {
        if (this._user) {
            this._user.disconnect(this._userLoadedId);
            this._user.disconnect(this._userChangedId);
            this._user = null;
        }
    }
};

var AuthenticationDialog = GObject.registerClass({
    Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } }
}, class AuthenticationDialog extends ModalDialog.ModalDialog {
    _init(actionId, description, cookie, userNames) {
        super._init({ styleClass: 'prompt-dialog' });

        this.actionId = actionId;
        this._cookie = cookie;
        this.message = description;
        this.userNames = userNames;
        this._wasDismissed = false;
        this._user = null;
        this._visibleAvatar = null;
        this._adminUsers = [];

        this._modalRetryCount = 0;

        this._sessionCompletedId = 0;
        this._sessionRequestId = 0;
        this._sessionShowErrorId = 0;
        this._sessionShowInfoId = 0;

        this._sessionRequestTimeoutId = 0;
        this._completed = false;
        this._doneEmitted = false;

        this.connect('closed', this._onDialogClosed.bind(this));

        let title = _("Authentication Required");

        let headerContent = new Dialog.MessageDialogContent({ title, description });
        this.contentLayout.add_child(headerContent);

        let bodyContent = new Dialog.MessageDialogContent();

        this._accountsService = AccountsService.UserManager.get_default();
        this._accountsService.list_users();

        let userBox = new St.BoxLayout({
            style_class: 'polkit-dialog-user-layout',
            important: true,
            vertical: true,
        });
        bodyContent.add_child(userBox);

        this._userCombo = new St.Button({
            style_class: 'polkit-dialog-user-combo',
        });
        this._userCombo.connect('clicked', this._onUserComboClicked.bind(this));

        const menuManager = new PopupMenu.PopupMenuManager({ actor: this._userCombo });
        this._menu = new PopupMenu.PopupMenu(this._userCombo, St.Side.TOP);
        Main.uiGroup.add_actor(this._menu.actor);
        this._menu.actor.hide();
        menuManager.addMenu(this._menu);

        // Collect all available users and populate the menu
        let nonASAdmins = []
        let have_admin = false;
        for (const name of userNames) {
            let userAcct = this._accountsService.get_user(name);
            if (!userAcct.is_loaded) {
                if (name === "root" || userAcct.is_system_account()) {
                    nonASAdmins.push(name);
                }
                continue
            }

            let adminUser = new AdminUser(userAcct);
            this._adminUsers.push(adminUser);

            userBox.add(adminUser.avatar, { x_fill: false });

            if (adminUser.realName !== null) {
                const realName = adminUser.realName;
                const userName = adminUser.userName;
                const item = new PopupMenu.PopupMenuItem(`${realName} (${userName})`);
                item.connect('activate', () => {
                    this._user = adminUser;
                    this._updateUser();
                    this._wasDismissed = true;
                    this.performAuthentication();
                });
                this._menu.addMenuItem(item);
            }

            have_admin = true;
        }

        if (!have_admin && nonASAdmins.length > 0) {
            for (const name of nonASAdmins) {
                let rootUser = new RootUser(name);
                this._adminUsers.push(rootUser);

                userBox.add(rootUser.avatar, { x_fill: false });

                const item = new PopupMenu.PopupMenuItem(name);
                item.connect('activate', () => {
                    this._user = rootUser;
                    this._updateUser();
                    this._wasDismissed = true;
                    this.performAuthentication();
                })
                this._menu.addMenuItem(item);
            }
        }

        // If the current user is an admin, set the current user
        let userFound = false;
        const currentUser = GLib.get_user_name();
        this._adminUsers.forEach(user => {
            if (user.userName === currentUser) {
                this._user = user;
                this._updateUser();
                userFound = true;
            }
        });

        // If the current user is not an admin, set the first user
        // as the active one. If there is more than a single user,
        // show the combo
        if (!userFound) {
            this._user = this._adminUsers[0];
            this._updateUser();
            this._userCombo.reactive = userNames.length > 1;
        }

        userBox.add(this._userCombo, { x_fill: false });

        let passwordBox = new St.BoxLayout({
            style_class: 'prompt-dialog-password-layout',
            vertical: true,
        });

        this._passwordEntry = new St.PasswordEntry({
            style_class: 'prompt-dialog-password-entry',
            text: "",
            can_focus: true,
            visible: false,
            x_align: Clutter.ActorAlign.CENTER,
        });
        CinnamonEntry.addContextMenu(this._passwordEntry);
        this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this));
        this._passwordEntry.bind_property('reactive',
            this._passwordEntry.clutter_text, 'editable',
            GObject.BindingFlags.SYNC_CREATE);
        passwordBox.add_child(this._passwordEntry);

        let warningBox = new St.BoxLayout({ vertical: true });

        let capsLockWarning = new CinnamonEntry.CapsLockWarning();
        this._passwordEntry.bind_property('visible',
            capsLockWarning, 'visible',
            GObject.BindingFlags.SYNC_CREATE);
        warningBox.add_child(capsLockWarning);

        this._errorMessageLabel = new St.Label({
            style_class: 'prompt-dialog-error-label',
            visible: false,
        });
        this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
        this._errorMessageLabel.clutter_text.line_wrap = true;

        warningBox.add_child(this._errorMessageLabel);

        this._infoMessageLabel = new St.Label({
            style_class: 'prompt-dialog-info-label',
            visible: false,
        });
        this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
        this._infoMessageLabel.clutter_text.line_wrap = true;

        warningBox.add_child(this._infoMessageLabel);

        /* text is intentionally non-blank otherwise the height is not the same as for
         * infoMessage and errorMessageLabel - but it is still invisible because
         * cinnamon.css sets the color to be transparent
         */
        this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label' });
        this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
        this._nullMessageLabel.clutter_text.line_wrap = true;

        warningBox.add_child(this._nullMessageLabel);

        passwordBox.add_child(warningBox);
        bodyContent.add_child(passwordBox);

        this._cancelButton = this.addButton({
            label: _("Cancel"),
            action: this.cancel.bind(this),
            key: Clutter.KEY_Escape
        });
        this._okButton = this.addButton({
            label:  _("Authenticate"),
            action: this._onAuthenticateButtonPressed.bind(this),
            reactive: false,
            default: true
        });
        this._okButton.bind_property('reactive',
            this._okButton, 'can-focus',
            GObject.BindingFlags.SYNC_CREATE);

        this._passwordEntry.clutter_text.connect('text-changed', text => {
            this._okButton.reactive = text.get_text().length > 0;
        });

        this.contentLayout.add_child(bodyContent);
    }

    _onUserComboClicked() {
        this._menu.toggle();
    }

    _updateUser() {
        global.log("Updating user");
        this._adminUsers.forEach(user => {
            if (user != this._user) {
                user.avatar.visible = false;
            } else {
                user.avatar.visible = true;
                this._userCombo.set_label(this._user.realName);
                this._identityToAuth = Polkit.UnixUser.new_for_name(user.userName);
            }
        });

        if (this._errorMessageLabel)
            this._errorMessageLabel.set_text("");
    }

    performAuthentication() {
        this._destroySession(DELAYED_RESET_TIMEOUT);
        this._session = new PolkitAgent.Session({
            identity: this._identityToAuth,
            cookie: this._cookie
        });
        this._sessionCompletedId = this._session.connect('completed', this._onSessionCompleted.bind(this));
        this._sessionRequestId = this._session.connect('request', this._onSessionRequest.bind(this));
        this._sessionShowErrorId = this._session.connect('show-error', this._onSessionShowError.bind(this));
        this._sessionShowInfoId = this._session.connect('show-info', this._onSessionShowInfo.bind(this));
        this._session.initiate();
    }

    _ensureOpen(focusEntry=false) {
        // NOTE: ModalDialog.open() is safe to call if the dialog is
        // already open - it just returns true without side-effects
        if (!this.open(global.get_current_time())) {
            // This can fail if e.g. unable to get input grab (double-click admin:// folder in nemo).
            if (this._modalRetryCount < MAX_MODAL_RETRIES) {
                this._modalRetryCount++;
                GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
                    this._ensureOpen(focusEntry);
                    return GLib.SOURCE_REMOVE;
                });

                return;
            }

            log('polkitAuthenticationAgent: Failed to show modal dialog.' +
                ' Dismissing authentication request for action-id ' + this.actionId +
                ' cookie ' + this._cookie);
            this._emitDone(true);
        } else {
            if (focusEntry) {
                this._passwordEntry.grab_key_focus();
            }
        }
    }

    _emitDone(dismissed) {
        if (!this._doneEmitted) {
            this._doneEmitted = true;
            this.emit('done', dismissed);
        }
    }

    _onEntryActivate() {
        let response = this._passwordEntry.get_text();
        if (response.length === 0)
            return;

        this._passwordEntry.reactive = false;
        this._okButton.reactive = false;

        this._session.response(response);
        // When the user responds, dismiss already shown info and
        // error texts (if any)
        this._errorMessageLabel.hide();
        this._infoMessageLabel.hide();
        this._nullMessageLabel.show();
    }

    _onAuthenticateButtonPressed() {
        this._onEntryActivate();
    }

    _onSessionCompleted(session, gainedAuthorization) {
        if (this._completed || this._doneEmitted)
            return;

        this._completed = true;

        if (gainedAuthorization) {
            this._emitDone(false);

        } else {
            /* Unless we are showing an existing error message from the PAM
             * module (the PAM module could be reporting the authentication
             * error providing authentication-method specific information),
             * show "Sorry, that didn't work. Please try again."
             */
            if (!this._errorMessageLabel.visible && !this._wasDismissed) {
                /* Translators: "that didn't work" refers to the fact that the
                 * requested authentication was not gained; this can happen
                 * because of an authentication error (like invalid password),
                 * for instance. */
                this._errorMessageLabel.set_text(_("Sorry, that didn\'t work. Please try again."));
                this._errorMessageLabel.show();
                this._infoMessageLabel.hide();
                this._nullMessageLabel.hide();

                Util.wiggle(this._passwordEntry);
            }

            this._wasDismissed = false;

            /* Try and authenticate again */
            this.performAuthentication();
        }
    }

    _onSessionRequest(session, request, echoOn) {
        if (this._sessionRequestTimeoutId) {
            GLib.source_remove(this._sessionRequestTimeoutId);
            this._sessionRequestTimeoutId = 0;
        }

        // Cheap localization trick
        if (request === 'Password:' || request === 'Password: ')
            this._passwordEntry.hint_text = _("Password");
        else
            this._passwordEntry.hint_text = request;

        this._passwordEntry.password_visible = echoOn;

        this._passwordEntry.show();
        this._passwordEntry.set_text('');
        this._passwordEntry.reactive  = true;
        this._okButton.reactive = false;

        this._ensureOpen(true);
    }

    _onSessionShowError(session, text) {
        this._passwordEntry.set_text('');
        this._errorMessageLabel.set_text(text);
        this._errorMessageLabel.show();
        this._infoMessageLabel.hide();
        this._nullMessageLabel.hide();
        this._ensureOpen();
    }

    _onSessionShowInfo(session, text) {
        this._passwordEntry.set_text('');
        this._infoMessageLabel.set_text(text);
        this._infoMessageLabel.show();
        this._errorMessageLabel.hide();
        this._nullMessageLabel.hide();
        this._ensureOpen();
    }

    _destroySession(delay = 0) {
        if (this._session) {
            if (!this._completed)
                this._session.cancel();
            this._completed = false;

            if (this._sessionCompletedId > 0) {
                this._session.disconnect(this._sessionCompletedId);
                this._session.disconnect(this._sessionRequestId);
                this._session.disconnect(this._sessionShowErrorId);
                this._session.disconnect(this._sessionShowInfoId);

                this._sessionCompletedId = 0;
                this._sessionRequestId = 0;
                this._sessionShowErrorId = 0;
                this._sessionShowInfoId = 0;
            }

            this._session = null;
        }

        if (this._sessionRequestTimeoutId > 0) {
            GLib.source_remove(this._sessionRequestTimeoutId);
            this._sessionRequestTimeoutId = 0;
        }

        let resetDialog = () => {
            if (this.state != ModalDialog.State.OPENED)
                return;

            this._passwordEntry.hide();
            this._cancelButton.grab_key_focus();
            this._okButton.reactive = false;
        };

        if (delay) {
            this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog);
            GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[cinnamon] this._sessionRequestTimeoutId');
        } else {
            resetDialog();
        }
    }

    cancel() {
        this._wasDismissed = true;
        this._emitDone(true);
    }

    _onDialogClosed() {
        if (this._sessionRequestTimeoutId)
            GLib.source_remove(this._sessionRequestTimeoutId);
        this._sessionRequestTimeoutId = 0;

        this._adminUsers.forEach(user => {
            user.destroy();
        });
        this._adminUsers = [];

        this._destroySession();
    }

});

var AuthenticationAgent = class {
    constructor() {
        this._native = new Cinnamon.PolkitAuthenticationAgent();
        this._native.connect('initiate', this._onInitiate.bind(this));
        this._native.connect('cancel', this._onCancel.bind(this));
        // TODO - maybe register probably should wait until later, especially at first login?
        try {
            const session = new GnomeSession.SessionManager();
            const csSessionId = session.SessionId;

            let procSessionId = this._native.register();
            if (procSessionId !== csSessionId) {
                global.logWarning("Unable to register as the polkit agent for login session '%s'. Cinnamon is running under '%s'"
                                  .format(csSessionId, procSessionId));
                this._native.unregister();
                this._native = null;

                if (Meta.is_wayland_compositor()) {
                    global.logWarning("Please log out and back in to correct this.")
                    return;
                }

                // Starting cinnamon over SSH can be useful when debugging and profiling. Provide a way to prevent a restart.
                if (!GLib.getenv("CINNAMON_ALLOW_SSH")) {
                    global.logWarning('Restarting Cinnamon in 5 seconds.');
                    GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 5, () => session.RestartCinnamonLauncherRemote())
                    return;
                }
            } else {
                global.log("Cinnamon registered as the Polkit agent for the active session (%s)".format(procSessionId));
            }
        } catch(e) {
            global.logWarning('Failed to register Polkit Agent', e);
        }
        this._currentDialog = null;
    }

    _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) {
        this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames);

        // We actually don't want to open the dialog until we know for
        // sure that we're going to interact with the user. For
        // example, if the password for the identity to auth is blank
        // (which it will be on a live CD) then there will be no
        // conversation at all... of course, we don't *know* that
        // until we actually try it.
        //
        // See https://bugzilla.gnome.org/show_bug.cgi?id=643062 for more
        // discussion.

        this._currentDialog.connect('done', this._onDialogDone.bind(this));
        this._currentDialog.performAuthentication();
    }

    _onCancel(nativeAgent) {
        this._completeRequest(false);
    }

    _onDialogDone(dialog, dismissed) {
        this._completeRequest(dismissed);
    }

    _completeRequest(dismissed) {
        this._currentDialog.close(global.get_current_time());
        this._currentDialog = null;

        this._native.complete(dismissed);
    }
}

function init() {
    let agent = new AuthenticationAgent();
}