% Implementation of PasswordProvider which uses the command window to
% prompt the user.

% Copyright 2020 The MathWorks, Inc.

classdef TextualPasswordProvider < parallel.internal.cluster.PasswordProvider
    
    properties (Constant)
        % Maximum number of characters to display before wrapping
        PromptMaxWidth = 80
        
        YesKey = "y"
        NoKey = "n"
        ContinueOptions = [parallel.internal.cluster.TextualPasswordProvider.YesKey ...
            parallel.internal.cluster.TextualPasswordProvider.NoKey]
        ContinueOptionsPrint = " (" + message('parallel:ui:PasswordPromptYes').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.YesKey + ">|" +  ...
            message('parallel:ui:PasswordPromptNo').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.NoKey + ">)"
        
        TemporarilyKey = "t"
        SessionKey = "s"
        ForeverKey = "f"
        RememberOptions = [parallel.internal.cluster.TextualPasswordProvider.TemporarilyKey ...
            parallel.internal.cluster.TextualPasswordProvider.SessionKey]
        RememberOptionsPrint = " (" + message('parallel:ui:PasswordPromptRememberTemporarily').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.TemporarilyKey + ">|" +  ...
            message('parallel:ui:PasswordPromptRememberSession').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.SessionKey + ">)"
        RememberOptionsWithCache = [parallel.internal.cluster.TextualPasswordProvider.RememberOptions ...
            parallel.internal.cluster.TextualPasswordProvider.ForeverKey]
        RememberOptionsWithCachePrint = " (" + message('parallel:ui:PasswordPromptRememberTemporarily').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.TemporarilyKey + ">|" +  ...
            message('parallel:ui:PasswordPromptRememberSession').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.SessionKey + ">|" + ...
            message('parallel:ui:PasswordPromptRememberForever').string + ...
            " <" + parallel.internal.cluster.TextualPasswordProvider.ForeverKey + ">)"
    end
    
    properties (SetAccess = immutable, GetAccess = private)
        % Hostname of cluster we are connected to
        Host
        
        % Name of cluster we are connected to
        Name
        
        % Instance of parallel.internal.cluster.TextualPasswordPrompt used
        % to prompt securely for passwords
        PasswordPrompt
        
        % Function handle to evaluate for input. Settable only for testing
        InputFunction
    end
    
    properties (Access = private)
        % Map of username to key to fetch password with
        PasswordMap
        
        % Map of username to remember choice
        RememberPasswordMap
        
        % Map of username to key to fetch password with. Note this map
        % is only populated when promptForChangePassword is called in case
        % the authorising user is also the user whose password is changed.
        % In this case we need both the old and new password.
        ChangePasswordMap
        
        % Flag indicating whether we should error rather than prompt the
        % user for input.
        PromptForPassword
    end
    
    methods        
        function obj = TextualPasswordProvider(host, name, inputFunctionHandle, ...
                readPasswordFunction)
            if nargin < 3
                inputFunctionHandle = @(promptText) input(promptText, 's');
            end
                       
            obj.Host = host;
            obj.Name = name;
            if nargin < 4
                obj.PasswordPrompt = parallel.internal.shared.TextualPasswordPrompt();
            else
                obj.PasswordPrompt = parallel.internal.shared.TextualPasswordPrompt(readPasswordFunction);
            end
            obj.InputFunction = inputFunctionHandle;
            % Initialise maps
            obj.clear();
        end
    end
    
    % PasswordProvider methods
    methods
        function setPromptForPassword(obj, promptForPassword)
            obj.PromptForPassword = promptForPassword;
        end

        function promptForPassword = getPromptForPassword(obj)
            promptForPassword = obj.PromptForPassword;
        end

        function username = promptForIdentity(obj, suggestedUserName, requiresSystemUser)
            obj.errorIfPromptDisabled();
            obj.displayTitle(message('parallel:ui:PasswordPromptTitleSetCurrentUser').string);
            if requiresSystemUser
                prompt = message('parallel:ui:PasswordPromptSetCurrentSystemUser', obj.Name, obj.Host).string;
            else
                prompt = message('parallel:ui:PasswordPromptSetCurrentUser', obj.Name, obj.Host).string;
            end
            obj.prompt(prompt);
            prompt = message('parallel:ui:PasswordPromptUsername').string;
            username = obj.promptForInput(prompt, suggestedUserName);
        end
        
        function promptForCurrentPassword(obj, username, errorCode, allowPasswordCache)
            obj.errorIfPromptDisabled();
            if ~isempty(errorCode) && errorCode.code == parallel.internal.cluster.types.AuthenticationErrorCode.InvalidUserPassword
                % Previous password was not valid
                obj.prompt(message('parallel:cluster:mjs:InvalidPassword', errorCode.user).string);
            else
                obj.displayTitle(message('parallel:ui:PasswordPromptTitleAuthenticate').string);
            end
            
            prompt = message('parallel:ui:PasswordPromptAuthenticate', username).string;
            key = obj.promptForPassword(prompt);
            obj.RememberPasswordMap(username) = obj.promptForRememberPassword(allowPasswordCache);
            obj.PasswordMap(username) = key;
        end
        
        function promptForNewPassword(obj, username, allowPasswordCache, requiresSystemUser)
            obj.errorIfPromptDisabled();
            obj.displayTitle(message('parallel:ui:PasswordPromptTitleAddNewUser').string);
            if requiresSystemUser
                % If password must be the system password, we do not ask the
                % user to confirm their new password.
                obj.prompt(message('parallel:ui:PasswordPromptAddNewSystemUser', obj.Name, obj.Host, username).string);
                keyNewPassword = obj.promptForPassword(...
                    message('parallel:ui:PasswordPromptPassword').string);
            else
                obj.prompt(message('parallel:ui:PasswordPromptAddNewUser', obj.Name, obj.Host, username).string);
                keyNewPassword = obj.promptForConfirmedPassword(...
                    message('parallel:ui:PasswordPromptNewPassword').string, ...
                    message('parallel:ui:PasswordPromptConfirmPassword').string);
            end
            obj.RememberPasswordMap(username) = obj.promptForRememberPassword(allowPasswordCache);
            obj.PasswordMap(username) = keyNewPassword;
        end

        function promptForChangePassword(obj, currentUser, username, errorCode, allowPasswordCache, requiresSystemUser)
            obj.errorIfPromptDisabled();
            sameUser = strcmp(currentUser, username);
            if ~isempty(errorCode) && errorCode.code == parallel.internal.cluster.types.AuthenticationErrorCode.InvalidUserPassword
                % Previous password was not valid
                obj.prompt(message('parallel:cluster:mjs:InvalidPassword', errorCode.user).string);
            else
                obj.displayTitle(message('parallel:ui:PasswordPromptTitleChangePassword').string);
                if requiresSystemUser
                    if sameUser
                        prompt = message('parallel:ui:PasswordPromptChangePasswordSameSystemUser', username).string;
                    else
                        prompt = message('parallel:ui:PasswordPromptChangePasswordDifferentSystemUser', username, currentUser).string;
                    end
                else
                    if sameUser
                        prompt = message('parallel:ui:PasswordPromptChangePasswordSameUser', username).string;
                    else
                        prompt = message('parallel:ui:PasswordPromptChangePasswordDifferentUser', username, currentUser).string;
                    end
                end
                obj.prompt(prompt);
            end
                       
            if sameUser
                currentPasswordPrompt = message('parallel:ui:PasswordPromptCurrentPassword').string;
                newPasswordPrompt = message('parallel:ui:PasswordPromptNewPassword').string;
                confirmPasswordPrompt = message('parallel:ui:PasswordPromptConfirmNewPassword').string;
            else
                currentPasswordPrompt = message('parallel:ui:PasswordPromptCurrentPasswordWithUser', currentUser).string;
                newPasswordPrompt = message('parallel:ui:PasswordPromptNewPasswordWithUser', username).string;
                confirmPasswordPrompt = message('parallel:ui:PasswordPromptConfirmNewPasswordWithUser', username).string;
            end
            key = obj.promptForPassword(currentPasswordPrompt);
            if requiresSystemUser
                % If password must be the system password, we do not ask the
                % user to confirm their new password.
                keyNewPassword = obj.promptForPassword(newPasswordPrompt);
            else
                keyNewPassword = obj.promptForConfirmedPassword(newPasswordPrompt, confirmPasswordPrompt);
            end
            obj.RememberPasswordMap(username) = obj.promptForRememberPassword(allowPasswordCache);
            obj.ChangePasswordMap(currentUser) = key;
            obj.PasswordMap(username) = keyNewPassword;
        end
        
        function username = promptForTemporaryUserSwitch(obj, owner, suggestedUser, errorCode, allowPasswordCache, requiresSystemUser)
            obj.errorIfPromptDisabled();
            if ~isempty(errorCode) && errorCode.code == parallel.internal.cluster.types.AuthenticationErrorCode.InvalidUserPassword
                % Previous password was not valid
                obj.prompt(message('parallel:cluster:mjs:InvalidPassword', errorCode.user).string);
            else
                obj.displayTitle(message('parallel:ui:PasswordPromptTitleSwitchUser').string);
                if ~isempty(errorCode) && errorCode.code == parallel.internal.cluster.types.AuthenticationErrorCode.NoAuthorisedUser
                    obj.prompt(message('parallel:cluster:mjs:NoAuthorisedUserFound').string);
                end
                if requiresSystemUser
                    prompt = message('parallel:ui:PasswordPromptSwitchSystemUser', owner).string;
                else
                    prompt = message('parallel:ui:PasswordPromptSwitchUser', owner).string;
                end
                obj.prompt(prompt);
            end
            
            usernamePrompt = message('parallel:ui:PasswordPromptUsername').string;
            username = obj.promptForInput(usernamePrompt, suggestedUser);
            key = obj.promptForPassword(message('parallel:ui:PasswordPromptPassword').string);
            obj.RememberPasswordMap(username) = obj.promptForRememberPassword(allowPasswordCache);
            obj.PasswordMap(username) = key;
        end
        
        function promptForAdminPassword(obj, adminUsername, errorCode, allowPasswordCache)
            obj.errorIfPromptDisabled();
            if ~isempty(errorCode) && errorCode.code == parallel.internal.cluster.types.AuthenticationErrorCode.InvalidAdminPassword
                % Previous password was not valid
                obj.prompt(message('parallel:cluster:mjs:InvalidAdminPassword').string);
            else
                obj.displayTitle(message('parallel:ui:PasswordPromptTitleSwitchUser').string);
                obj.prompt(message('parallel:ui:PasswordPromptAdminUser').string);
            end
            
            key = obj.promptForPassword(message('parallel:ui:PasswordPromptPasswordWithUser', adminUsername).string);
            obj.RememberPasswordMap(adminUsername) = obj.promptForRememberPassword(allowPasswordCache);
            obj.PasswordMap(adminUsername) = key;
        end
        
        function issueNotOwnerWarning(obj, currentUser, owner)
            obj.errorIfPromptDisabled();
            obj.displayTitle(message('parallel:ui:PasswordPromptTitleSwitchUser').string);
            prompt = message('parallel:ui:PasswordPromptSwitchUserWarning', owner).string;
            obj.prompt(prompt);
            if ~obj.promptContinue()
                error(message('parallel:cluster:mjs:NoCredentialsEntered', currentUser));
            end
        end
        
        function encryptedBytes = fetchEncryptedPassword(obj, encryptor, stringEncoder, username)
            assert(isKey(obj.PasswordMap, username), sprintf('Password key not found for %s', username));
            encryptedBytes = obj.PasswordPrompt.fetchEncryptedPassword(obj.PasswordMap(username), encryptor, stringEncoder);
        end
        
        function [hashedBytes, rememberChoice] = fetchHashedPassword(obj, hasher, stringEncoder, username)
            assert(isKey(obj.PasswordMap, username), sprintf('Password key not found for %s', username));
            assert(isKey(obj.RememberPasswordMap, username), sprintf('Remember password key not found for %s', username));
            hashedBytes = obj.PasswordPrompt.fetchHashedPassword(obj.PasswordMap(username), hasher, stringEncoder);
            if isKey(obj.RememberPasswordMap, username)
                rememberChoice = obj.RememberPasswordMap(username);
            else
                rememberChoice = parallel.internal.types.RememberPassword.Never;
            end
        end
        
        function hashedBytes = fetchHashedChangePassword(obj, hasher, stringEncoder, username)
            assert(isKey(obj.ChangePasswordMap, username), sprintf('Password key not found for %s', username));
            hashedBytes = obj.PasswordPrompt.fetchHashedPassword(obj.ChangePasswordMap(username), hasher, stringEncoder);
        end

        function clear(obj)
            obj.PasswordPrompt.clear();
            obj.PasswordMap = containers.Map('KeyType', 'char', 'ValueType', 'uint64');
            obj.RememberPasswordMap = containers.Map('KeyType', 'char', 'ValueType', 'any');
            obj.ChangePasswordMap = containers.Map('KeyType', 'char', 'ValueType', 'uint64');
        end
    end
    
    methods (Access = private)
        function text = wrapTextIfRequired(obj, text)
            text = strjoin(parallel.internal.general.splitFixedWidthString(text, obj.PromptMaxWidth), '\n');
        end
        
        function prompt(obj, text)
            disp(obj.wrapTextIfRequired(text));
        end
        
        function errorIfPromptDisabled(obj)
            if ~obj.PromptForPassword
                error(message('parallel:cluster:mjs:NoPasswordPrompt'));
            end
        end
        
        function userValue = promptForInput(obj, promptText, defaultValue)
            haveDefault = false;
            promptText = obj.wrapTextIfRequired(promptText);
            if nargin > 1
                promptText = promptText + " [ " + defaultValue + " ]";
                haveDefault = true;
            end
            promptText = promptText + ": ";
            
            userValue = obj.InputFunction(promptText);
            if ~isempty(userValue)
                return
            end
            if haveDefault
                userValue = defaultValue;
                return
            end
            obj.prompt(message('parallel:ui:PasswordPromptEmptyReply').string);
        end
        
        function key = promptForPassword(obj, promptText)
            promptText = obj.wrapTextIfRequired(promptText);
            promptText = promptText + ": ";
            while true
                try
                    key = obj.PasswordPrompt.promptForPassword(promptText);
                    return
                catch E
                    if strcmp(E.identifier, 'parallel:internal:EmptyPassword')
                        obj.prompt(message('parallel:ui:PasswordPromptEmptyReply').string);
                        obj.prompt(message('parallel:ui:PasswordPromptTryAgain').string);
                    else
                        rethrow(E);
                    end
                end
            end
        end
        
        function keyNewPassword = promptForConfirmedPassword(obj, prompt, confirmPrompt)
            confirmPrompt = confirmPrompt + ": ";
            while true
                try
                    keyNewPassword = obj.promptForPassword(prompt);
                    obj.PasswordPrompt.promptForMatchingPassword(confirmPrompt, keyNewPassword);
                    break
                catch E
                    if strcmp(E.identifier, 'parallel:internal:ConfirmPasswordDoesNotMatch')
                        obj.prompt(message('parallel:ui:PasswordPromptInvalidPasswordConfirmation').string);
                        obj.prompt(message('parallel:ui:PasswordPromptTryAgain').string);
                    elseif strcmp(E.identifier, 'parallel:internal:EmptyPassword')
                        obj.prompt(message('parallel:ui:PasswordPromptEmptyReply').string);
                        obj.prompt(message('parallel:ui:PasswordPromptTryAgain').string);
                    else
                        rethrow(E);
                    end
                end
            end
        end
        
        function rememberChoice = promptForRememberPassword(obj, allowPasswordCache)
            rememberPrompt = message('parallel:ui:PasswordPromptRememberPassword').string;
            if allowPasswordCache
                rememberPrompt = rememberPrompt + obj.RememberOptionsWithCachePrint;
                rememberChoiceOptions = obj.RememberOptionsWithCache;
            else
                rememberPrompt = rememberPrompt + obj.RememberOptionsPrint;
                rememberChoiceOptions = obj.RememberOptions;
            end
            while true
                rememberChoice = obj.promptForInput(rememberPrompt, obj.SessionKey);
                if ~ismember(rememberChoice, rememberChoiceOptions)
                    obj.prompt(message('parallel:ui:PasswordPromptInvalidOption', rememberChoice).string);
                else
                    break
                end
            end
            rememberChoice = obj.getRememberChoice(rememberChoice);
        end
        
        function continueChoice = promptContinue(obj)
            continuePrompt = message('parallel:ui:PasswordPromptContinue').string;
            continuePrompt = continuePrompt + obj.ContinueOptionsPrint;
            while true
                continueChoice = obj.promptForInput(continuePrompt, obj.NoKey);
                if ~ismember(continueChoice, obj.ContinueOptions)
                    obj.prompt(message('parallel:ui:PasswordPromptInvalidOption', continueChoice).string);
                else
                    break
                end
            end
            continueChoice = strcmp(continueChoice, obj.YesKey);
        end
        
        function rememberChoice = getRememberChoice(obj, input)
            import parallel.internal.types.RememberPassword;
            switch input
                case obj.TemporarilyKey
                    rememberChoice = RememberPassword.Temporary;
                case obj.SessionKey
                    rememberChoice = RememberPassword.Session;
                case obj.ForeverKey
                    rememberChoice = RememberPassword.Always;
                otherwise
                    rememberChoice = RememberPassword.Never;
            end
        end
        
        function displayTitle(~, title)
            disp("*** " + title + " ***");
        end
    end
end
