/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 *
 */
package org.apache.causeway.extensions.secman.delegated.shiro.realm;

import java.util.concurrent.Callable;
import java.util.function.Supplier;

import jakarta.inject.Inject;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.CredentialsException;
import org.apache.shiro.authc.DisabledAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthenticatingRealm;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import org.apache.causeway.applib.services.iactnlayer.InteractionService;
import org.apache.causeway.applib.services.inject.ServiceInjector;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.base._NullSafe;
import org.apache.causeway.commons.internal.collections._Arrays;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.core.config.CausewayConfiguration.Extensions.Secman.DelegatedUsers.AutoCreatePolicy;
import org.apache.causeway.core.security.authorization.Authorizor;
import org.apache.causeway.extensions.secman.applib.user.dom.AccountType;
import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserRepository;
import org.apache.causeway.extensions.secman.delegated.shiro.util.ShiroUtils;

import lombok.Getter;
import lombok.Setter;

/**
 * @since 2.0 {@index}
 */
public class CausewayModuleExtSecmanShiroRealm extends AuthorizingRealm {

    private static final String SECMAN_UNLOCK_DELEGATED_USERS = "causeway.ext.secman.unlockDelegatedUsers";
	@Inject protected ServiceInjector serviceInjector;
    @Inject protected InteractionService interactionService;
    @Inject protected PlatformTransactionManager txMan;
	@Inject protected CausewayConfiguration config;

    @Getter @Setter private AuthenticatingRealm delegateAuthenticationRealm;
    @Getter @Setter private boolean autoCreateUser = true;

    /**
     * Configures a {@link org.apache.shiro.authz.permission.PermissionResolver} that knows how to process the
     * permission strings that are provided by Causeway'
     * {@link Authorizor} for Shiro.
     */
    public CausewayModuleExtSecmanShiroRealm() {
        setPermissionResolver(new PermissionResolverForCausewayShiroAuthorizor());
    }

    /**
     * In order to not provide an attacker with additional information, the exceptions thrown here deliberately have
     * few (or no) details in their exception message. Similarly, the generic
     * {@link org.apache.shiro.authc.CredentialsException} is thrown for both a non-existent user and also an
     * invalid password.
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(final AuthenticationToken token) throws AuthenticationException {
        if (!(token instanceof UsernamePasswordToken)) {
            throw new AuthenticationException();
        }

        var usernamePasswordToken = (UsernamePasswordToken) token;
        var username = usernamePasswordToken.getUsername();
        var password = usernamePasswordToken.getPassword();

        // this code block is just an optimization, entirely optional
        {
            var alreadyAuthenticatedPrincipal =
                    getPrincipal_fromAlreadyAuthenticatedSubjectIfApplicable(token);
            if(alreadyAuthenticatedPrincipal!=null) {
                var credentials = token.getCredentials();
                var realmName = getName();
                return AuthInfoForApplicationUser.of(alreadyAuthenticatedPrincipal, realmName, credentials);
            }
        }

        // lookup from database, for roles/perms
        PrincipalForApplicationUser principal = lookupPrincipal_inApplicationUserRepository(username);

        var autoCreateUserWhenDelegatedAuthentication = hasDelegateAuthenticationRealm() && isAutoCreateUser();
        if (principal == null && autoCreateUserWhenDelegatedAuthentication) {
            // When using delegated authentication, desired behavior is to auto-create user accounts in the
            // DB only if these do successfully authenticate with the delegated authentication mechanism
            // while the newly created user will be disabled by default
            authenticateElseThrow_usingDelegatedMechanism(token);
            var newPrincipal = createPrincipal_inApplicationUserRepository(username);

            _Assert.assertNotNull(newPrincipal);

            var isAutoUnlockIfDelegatedAndAuthenticated =
                    config.getExtensions().getSecman().getDelegatedUsers().getAutoCreatePolicy()
                        == AutoCreatePolicy.AUTO_CREATE_AS_UNLOCKED;

            if(isAutoUnlockIfDelegatedAndAuthenticated) {
                principal = newPrincipal;
            } else {
                _Assert.assertTrue(newPrincipal.isLocked(),
                        ()->"As configured in " + SECMAN_UNLOCK_DELEGATED_USERS + ", auto-created user accounts are initially locked!");
                throw disabledAccountException(username); // default behavior after user auto-creation
            }
        }

        if (principal == null) {
            throw credentialsException();
        }

        if (principal.isLocked()) {
            throw disabledAccountException(principal.getUsername());
        }

        if(principal.getAccountType() == AccountType.DELEGATED) {
            authenticateElseThrow_usingDelegatedMechanism(token);
        } else {
            var checkPasswordResult = checkPassword(password, principal.getEncryptedPassword());
            switch (checkPasswordResult) {
            case OK:
                break;
            case BAD_PASSWORD:
                throw credentialsException();
            case NO_PASSWORD_ENCRYPTION_SERVICE_CONFIGURED:
                throw new AuthenticationException("No password encryption service is installed");
            default:
                throw new AuthenticationException();
            }
        }

        var credentials = token.getCredentials();
        var realmName = getName();
        return AuthInfoForApplicationUser.of(principal, realmName, credentials);
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(final PrincipalCollection principals) {
        return principals.oneByType(PrincipalForApplicationUser.class);
    }

    // -- HELPER

    /**
     * @implNote
     * This is just an optimization, entirely optional.
     * <p>
     * We reuse principal information on subjects that are already authenticated,
     * provided we are in a single realm authentication scenario.
     * @param token
     * @return {@code null} if not applicable
     */
    private PrincipalForApplicationUser getPrincipal_fromAlreadyAuthenticatedSubjectIfApplicable(
            final AuthenticationToken token) {

        // this optimization is only implemented for the simple case of a single realm setup
        if(!ShiroUtils.isSingleRealm()) {
            return null;
        }

        var currentSubject = SecurityUtils.getSubject();
        if(currentSubject!=null && currentSubject.isAuthenticated()) {
            var authenticatedPrincipalObject = currentSubject.getPrincipal();
            if(authenticatedPrincipalObject instanceof PrincipalForApplicationUser) {
                var authenticatedPrincipal = (PrincipalForApplicationUser) authenticatedPrincipalObject;
                var authenticatedUsername = authenticatedPrincipal.getUsername();
                var usernamePasswordToken = (UsernamePasswordToken) token;
                var username = usernamePasswordToken.getUsername();
                var isAuthenticatedWithThisRealm = username.equals(authenticatedUsername);
                if(isAuthenticatedWithThisRealm) {
                    return authenticatedPrincipal;
                }
            }
        }
        return null;
    }

    private DisabledAccountException disabledAccountException(final String username) {
        return new DisabledAccountException(String.format("username='%s'", username));
    }

    private CredentialsException credentialsException() {
        return new CredentialsException("Unknown user/password combination") {
            private static final long serialVersionUID = 1L;
            @Override public StackTraceElement[] getStackTrace() {
                // truncate reported stacktraces down to just 1 line
                var fullStackTrace = super.getStackTrace();
                return _NullSafe.size(fullStackTrace)>1
                        ? _Arrays.subArray(super.getStackTrace(), 0, 1)
                        : fullStackTrace;
            }
        };
    }

    private void authenticateElseThrow_usingDelegatedMechanism(final AuthenticationToken token) {
        AuthenticationInfo delegateAccount = null;
        try {
            delegateAccount = delegateAuthenticationRealm.getAuthenticationInfo(token);
        } catch (AuthenticationException ex) {
            // fall through
        }
        if(delegateAccount == null) {
            throw credentialsException();
        }
    }

    private PrincipalForApplicationUser lookupPrincipal_inApplicationUserRepository(final String username) {

        return execute(new Supplier<PrincipalForApplicationUser>() {
            @Override
            public PrincipalForApplicationUser get() {
                var applicationUser = applicationUserRepository.findByUsername(username).orElse(null);
                return PrincipalForApplicationUser.from(applicationUser);
            }
            @Inject private ApplicationUserRepository applicationUserRepository;
        });
    }

    private PrincipalForApplicationUser createPrincipal_inApplicationUserRepository(final String username) {

        return execute(new Supplier<PrincipalForApplicationUser>() {
            @Override
            public PrincipalForApplicationUser get() {
                var applicationUser = applicationUserRepository.findOrCreateUserByUsername(username);
                return PrincipalForApplicationUser.from(applicationUser);
            }
            @Inject private ApplicationUserRepository applicationUserRepository;
        });
    }

    private static enum CheckPasswordResult {
        OK,
        BAD_PASSWORD,
        NO_PASSWORD_ENCRYPTION_SERVICE_CONFIGURED
    }

    private CheckPasswordResult checkPassword(final char[] candidate, final String actualEncryptedPassword) {
        return execute(new Supplier<CheckPasswordResult>() {

            @Autowired(required = false) private @Qualifier("Secman") PasswordEncoder passwordEncoder;

            @Override
            public CheckPasswordResult get() {
                if (passwordEncoder == null) {
                    return CheckPasswordResult.NO_PASSWORD_ENCRYPTION_SERVICE_CONFIGURED;
                }
                return passwordEncoder.matches(new String(candidate), actualEncryptedPassword)
                        ? CheckPasswordResult.OK
                        : CheckPasswordResult.BAD_PASSWORD;
            }

        });
    }

    private boolean hasDelegateAuthenticationRealm() {
        return delegateAuthenticationRealm != null;
    }

    <V> V execute(final Supplier<V> closure) {
        return interactionService.callAnonymous(
                new Callable<V>() {
                    @Override
                    public V call() {
                        serviceInjector.injectServicesInto(closure);
                        return doExecute(closure);
                    }
                }
                );
    }

    <V> V doExecute(final Supplier<V> closure) {
        var txTemplate = new TransactionTemplate(txMan);
        return txTemplate.execute(status->closure.get());
    }

}
