import AccountsService from 'gi://AccountsService';
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import PolkitAgent from 'gi://PolkitAgent';
import Polkit from 'gi://Polkit';
import Shell from 'gi://Shell';
import St from 'gi://St';

import * as Dialog from '../dialog.js';
import * as Main from '../main.js';
import * as ModalDialog from '../modalDialog.js';
import * as ShellEntry from '../shellEntry.js';
import * as UserWidget from '../userWidget.js';
import {wiggle} from '../../misc/animationUtils.js';

/** @enum {number} */
const DialogMode = {
    AUTH: 0,
    CONFIRM: 1,
};

const DIALOG_ICON_SIZE = 96;

const DELAYED_RESET_TIMEOUT = 200;

const 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.message = description;
        this.userNames = userNames;

        Main.sessionMode.connectObject('updated', () => {
            this.visible = !Main.sessionMode.isLocked;
        }, this);

        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();

        if (userNames.length > 1) {
            log(`polkitAuthenticationAgent: Received ${userNames.length} ` +
                'identities that can be used for authentication. Only ' +
                'considering one.');
        }

        let userName = GLib.get_user_name();
        if (!userNames.includes(userName))
            userName = 'root';
        if (!userNames.includes(userName))
            userName = userNames[0];

        this._user = AccountsService.UserManager.get_default().get_user(userName);

        let userBox = new St.BoxLayout({
            style_class: 'polkit-dialog-user-layout',
            orientation: Clutter.Orientation.VERTICAL,
        });
        bodyContent.add_child(userBox);

        this._userAvatar = new UserWidget.Avatar(this._user, {
            iconSize: DIALOG_ICON_SIZE,
        });
        this._userAvatar.x_align = Clutter.ActorAlign.CENTER;
        userBox.add_child(this._userAvatar);

        this._userLabel = new St.Label({
            style_class: userName === 'root'
                ? 'polkit-dialog-user-root-label'
                : 'polkit-dialog-user-label',
        });

        if (userName === 'root')
            this._userLabel.text = _('Administrator');

        userBox.add_child(this._userLabel);

        let passwordBox = new St.BoxLayout({
            style_class: 'prompt-dialog-password-layout',
            orientation: Clutter.Orientation.VERTICAL,
        });

        this._passwordEntry = new St.PasswordEntry({
            style_class: 'prompt-dialog-password-entry',
            text: '',
            can_focus: true,
            visible: false,
            x_align: Clutter.ActorAlign.CENTER,
        });
        ShellEntry.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({orientation: Clutter.Orientation.VERTICAL});

        let capsLockWarning = new ShellEntry.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
         * gnome-shell.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,
        });
        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);

        this._doneEmitted = false;

        this._mode = -1;

        this._identityToAuth = Polkit.UnixUser.new_for_name(userName);
        this._cookie = cookie;

        this._user.connectObject(
            'notify::is-loaded', this._onUserChanged.bind(this),
            'changed', this._onUserChanged.bind(this), this);
        this._onUserChanged();
    }

    _initiateSession() {
        this._destroySession(DELAYED_RESET_TIMEOUT);

        this._session = new PolkitAgent.Session({
            identity: this._identityToAuth,
            cookie: this._cookie,
        });
        this._session.connectObject(
            'completed', this._onSessionCompleted.bind(this),
            'request', this._onSessionRequest.bind(this),
            'show-error', this._onSessionShowError.bind(this),
            'show-info', this._onSessionShowInfo.bind(this), this);
        this._session.initiate();
    }

    _ensureOpen() {
        // NOTE: ModalDialog.open() is safe to call if the dialog is
        // already open - it just returns true without side-effects
        if (!this.open()) {
            // This can fail if e.g. unable to get input grab
            //
            // In an ideal world this wouldn't happen (because the
            // Shell is in complete control of the session) but that's
            // just not how things work right now.
            //
            // One way to make this happen is by running 'sleep 3;
            // pkexec bash' and then opening a popup menu.
            //
            // We could add retrying if this turns out to be a problem

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

    _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() {
        if (this._mode === DialogMode.CONFIRM)
            this._initiateSession();
        else
            this._onEntryActivate();
    }

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

        this._completed = true;

        /* Yay, all done */
        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) {
                /* 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();

                wiggle(this._passwordEntry);
            }

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

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

        // Hack: The request string comes directly from PAM, if it's "Password:"
        // we replace it with our own to allow localization, if it's something
        // else we remove the last colon and any trailing or leading spaces.
        if (request === 'Password:' || request === 'Password: ')
            this._passwordEntry.hint_text = _('Password');
        else
            this._passwordEntry.hint_text = request.replace(/: *$/, '').trim();

        this._passwordEntry.password_visible = echoOn;

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

        this._ensureOpen();
        this._passwordEntry.grab_key_focus();
    }

    _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) {
        this._session?.disconnectObject(this);

        if (!this._completed)
            this._session?.cancel();

        this._completed = false;
        this._session = null;

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

        let resetDialog = () => {
            this._sessionRequestTimeoutId = 0;

            if (this.state !== ModalDialog.State.OPENED)
                return GLib.SOURCE_REMOVE;

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

            return GLib.SOURCE_REMOVE;
        };

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

    _onUserChanged() {
        if (!this._user.is_loaded)
            return;

        let userName = this._user.get_user_name();
        let realName = this._user.get_real_name();

        if (userName !== 'root')
            this._userLabel.set_text(realName);

        this._userAvatar.update();

        if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) {
            if (this._mode === DialogMode.CONFIRM)
                return;

            this._mode = DialogMode.CONFIRM;
            this._destroySession();

            this._okButton.reactive = true;

            /* We normally open the dialog when we get a "request" signal, but
             * since in this case initiating a session would perform the
             * authentication, only open the dialog and initiate the session
             * when the user confirmed. */
            this._ensureOpen();
        } else {
            if (this._mode === DialogMode.AUTH)
                return;

            this._mode = DialogMode.AUTH;
            this._initiateSession();
        }
    }

    close(timestamp) {
        // Ensure cleanup if the dialog was never shown
        if (this.state === ModalDialog.State.CLOSED)
            this._onDialogClosed();
        super.close(timestamp);
    }

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

    _onDialogClosed() {
        Main.sessionMode.disconnectObject(this);

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

        this._user?.disconnectObject(this);
        this._user = null;

        this._destroySession();
    }
});

const AuthenticationAgent = GObject.registerClass(
class AuthenticationAgent extends Shell.PolkitAuthenticationAgent {
    _init() {
        super._init();

        this._currentDialog = null;
        this.connect('initiate', this._onInitiate.bind(this));
        this.connect('cancel', this._onCancel.bind(this));
        this._sessionUpdatedId = 0;
    }

    enable() {
        try {
            this.register();
        } catch {
            log('Failed to register AuthenticationAgent');
        }
    }

    disable() {
        try {
            this.unregister();
        } catch {
            log('Failed to unregister AuthenticationAgent');
        }
    }

    _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) {
        // Don't pop up a dialog while locked
        if (Main.sessionMode.isLocked) {
            Main.sessionMode.connectObject('updated', () => {
                Main.sessionMode.disconnectObject(this);

                this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames);
            }, this);
            return;
        }

        this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames);
        this._currentDialog.connect('done', this._onDialogDone.bind(this));
    }

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

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

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

        Main.sessionMode.disconnectObject(this);

        this.complete(dismissed);
    }
});

export {AuthenticationAgent as Component};
