/*
 *  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.isis.security.shiro;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;

import com.google.common.collect.Lists;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
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.ExcessiveAttemptsException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.mgt.RealmSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.isis.applib.Identifier;
import org.apache.isis.core.commons.authentication.AuthenticationSession;
import org.apache.isis.core.commons.config.IsisConfiguration;
import org.apache.isis.core.metamodel.deployment.DeploymentCategory;
import org.apache.isis.core.runtime.authentication.AuthenticationManagerInstaller;
import org.apache.isis.core.runtime.authentication.AuthenticationRequest;
import org.apache.isis.core.runtime.authentication.AuthenticationRequestPassword;
import org.apache.isis.core.runtime.authentication.standard.Authenticator;
import org.apache.isis.core.runtime.authentication.standard.SimpleSession;
import org.apache.isis.core.runtime.authorization.AuthorizationManagerInstaller;
import org.apache.isis.core.runtime.authorization.standard.Authorizor;
import org.apache.isis.security.shiro.authorization.IsisPermission;

/**
 * If Shiro is configured for both {@link AuthenticationManagerInstaller authentication} and
 * {@link AuthorizationManagerInstaller authorization} (as recommended), then this class is
 * instantiated twice, once in the role of {@link Authenticator} and once in the role of the {@link Authorizor}.
 * 
 * <p>
 * However, although there are two objects, they are set up to share the same {@link SecurityManager Shiro SecurityManager}
 * (bound to a thread-local).
 */
public class ShiroAuthenticatorOrAuthorizor implements Authenticator, Authorizor {

    private static final Logger LOG = LoggerFactory.getLogger(ShiroAuthenticatorOrAuthorizor.class);

    private static final String ISIS_AUTHENTICATION_SHIRO_AUTO_LOGOUT_KEY = "isis.authentication.shiro.autoLogoutIfAlreadyAuthenticated";
    private static final boolean ISIS_AUTHENTICATION_SHIRO_AUTO_LOGOUT_DEFAULT = false;

    //region > constructor and fields
    private final IsisConfiguration configuration;
    private final boolean autoLogout;

    private DeploymentCategory deploymentCategory;

    public ShiroAuthenticatorOrAuthorizor(final IsisConfiguration configuration) {
        this.configuration = configuration;
        autoLogout = configuration.getBoolean(
                ISIS_AUTHENTICATION_SHIRO_AUTO_LOGOUT_KEY,
                ISIS_AUTHENTICATION_SHIRO_AUTO_LOGOUT_DEFAULT);
    }

    public IsisConfiguration getConfiguration() {
        return configuration;
    }

    //endregion

    //region > init, shutdown

    @Override
    public void init(final DeploymentCategory deploymentCategory) {
        this.deploymentCategory = deploymentCategory;
    }


    @Override
    public void shutdown() {
    }

    @Override
    public DeploymentCategory getDeploymentCategory() {
        return deploymentCategory;
    }

    //endregion

    //region > Authenticator API

    @Override
    public final boolean canAuthenticate(final Class<? extends AuthenticationRequest> authenticationRequestClass) {
        if(getSecurityManager() == null) {
            return false;
        }
        return AuthenticationRequestPassword.class.isAssignableFrom(authenticationRequestClass);
    }

    @Override
    public AuthenticationSession authenticate(final AuthenticationRequest request, final String code) {
        RealmSecurityManager securityManager = getSecurityManager();
        if(securityManager == null) {
            return null;
        }
        final AuthenticationToken token = asAuthenticationToken(request);
        
        final Subject currentSubject = SecurityUtils.getSubject();
        if(currentSubject.isAuthenticated()) {

            if(autoLogout) {
                // this is preserving behaviour pre 1.13.0.  However, there is a suspicion that this might
                // produce a race condition.  In 1.13.0 the default is to simply reuse the session.
                //
                // See this thread for further info: http://markmail.org/message/hsjljwgkhhrzxbrm
                currentSubject.logout();
            } else {

                // TODO: should we verify the code passed in that this session is still alive?
                // TODO: perhaps we should cache Isis' AuthenticationSession inside the Shiro Session, and just retrieve it?

                return authenticationSessionFor(request, code, token, currentSubject);
            }
        }
        try {
            currentSubject.login(token);
        } catch ( UnknownAccountException uae ) { 
            LOG.info("Unknown account: {}", request.getName());
            return null;
        } catch ( IncorrectCredentialsException ice ) {
            LOG.info("Incorrect credentials for user: {}", request.getName());
            return null;
        } catch ( CredentialsException ice ) {
            LOG.error("Unable to authenticate", ice);
            return null;
        } catch ( LockedAccountException lae ) {
            LOG.info("Locked account for user: {}", request.getName());
            return null;
        } catch ( ExcessiveAttemptsException eae ) { 
            LOG.info("Excessive attempts for user: {}", request.getName());
            return null;
        } catch ( AuthenticationException ae ) {
            LOG.error("Unable to authenticate", ae);
            return null;
        }

        return authenticationSessionFor(request, code, token, currentSubject);
    }

    @Override
    public void logout(final AuthenticationSession session) {
        Subject currentSubject = SecurityUtils.getSubject();
        if(currentSubject.isAuthenticated()) {
            currentSubject.logout();
        }
    }

    AuthenticationSession authenticationSessionFor(AuthenticationRequest request, String code, AuthenticationToken token, Subject currentSubject) {
        List<String> roles = getRoles(currentSubject, token);
        // copy over any roles passed in
        // (this is used by the Wicket viewer, for example).
        roles.addAll(request.getRoles());

        return new SimpleSession(request.getName(), roles, code);
    }

    /**
     * This method has protected visibility to allow for custom implementations
     * in the future that might obtain the list of roles for a principal from
     * somewhere other than Shiro's {@link RealmSecurityManager}.
     */
    protected List<String> getRoles(final Subject subject, final AuthenticationToken token) {
        final List<String> roles = Lists.newArrayList();

        RealmSecurityManager securityManager = getSecurityManager();
        if(securityManager == null) {
            return roles;
        }
        
        final Set<String> realmNames = realmNamesOf(subject);
        final Collection<Realm> realms = securityManager.getRealms();
        for (final Realm realm : realms) {
            // only obtain roles from those realm(s) that authenticated this subject
            if(!realmNames.contains(realm.getName())) {
                continue;
            }
            final AuthenticationInfo authenticationInfo = realm.getAuthenticationInfo(token);
            if(authenticationInfo instanceof AuthorizationInfo) {
                final AuthorizationInfo authorizationInfo = (AuthorizationInfo) authenticationInfo;
                final Collection<String> realmRoles = authorizationInfo.getRoles();
                for (final String role : realmRoles) {
                    roles.add(realm.getName() + ":" + role);
                }
            }
        }
        return roles;
    }

    private static Set<String> realmNamesOf(final Subject subject) {
        final PrincipalCollection principals = subject.getPrincipals();
        return principals != null? principals.getRealmNames(): Collections.<String>emptySet();
    }

    private static AuthenticationToken asAuthenticationToken(final AuthenticationRequest request) {
        final AuthenticationRequestPassword passwordRequest = (AuthenticationRequestPassword) request;
        final String username = passwordRequest.getName();
        final String password = passwordRequest.getPassword();
        
        return new UsernamePasswordToken(username, password);
    }
    //endregion

    //region > Authorizor API

    @Override
    public boolean isVisibleInAnyRole(Identifier identifier) {
        return isPermitted(identifier, "r");
    }

    @Override
    public boolean isUsableInAnyRole(Identifier identifier) {
        return isPermitted(identifier, "w");
    }

    private boolean isPermitted(Identifier identifier, String qualifier) {
        RealmSecurityManager securityManager = getSecurityManager();
        if(securityManager == null) {
              // since a security manager will always be present for regular web requests, presumably the user
              // is running in fixtures during bootstrapping.  We therefore permit the interaction.
            return true;
        }

        String permission = asPermissionsString(identifier) + ":" + qualifier;

        Subject subject = SecurityUtils.getSubject();
        
        try {
            return subject.isPermitted(permission);
        } finally {
            IsisPermission.resetVetoedPermissions();
        }
    }

    private static String asPermissionsString(Identifier identifier) {
        String fullyQualifiedClassName = identifier.getClassName();
        int lastDot = fullyQualifiedClassName.lastIndexOf('.');
        String packageName;
        String className;
        if(lastDot > 0) {
            packageName =fullyQualifiedClassName.substring(0, lastDot);
            className = fullyQualifiedClassName.substring(lastDot+1);
        } else {
            packageName = "";
            className = fullyQualifiedClassName;
        }
        return packageName + ":" + className + ":" + identifier.getMemberName();
    }

    /**
     * Returns <tt>false</tt> because the checking across all roles is done in
     * {@link #isVisibleInAnyRole(Identifier)}, which is always called prior to this.
     */
    @Override
    public boolean isVisibleInRole(String role, Identifier identifier) {
        return false;
    }

    /**
     * Returns <tt>false</tt> because the checking across all roles is done in
     * {@link #isUsableInAnyRole(Identifier)}, which is always called prior to this.
     */
    @Override
    public boolean isUsableInRole(String role, Identifier identifier) {
        return false;
    }

    //endregion

    //region > Injected (via Shiro service locator)

    /**
     * The {@link SecurityManager} is shared between both the {@link Authenticator} and the {@link Authorizor}
     * (if shiro is configured for both components).
     */
    protected RealmSecurityManager getSecurityManager() {
        SecurityManager securityManager;
        try {
            securityManager = SecurityUtils.getSecurityManager();
        } catch(UnavailableSecurityManagerException ex) {
            return null;
        }
        if(!(securityManager instanceof RealmSecurityManager)) {
            return null;
        }
        return (RealmSecurityManager) securityManager;
    }

    //endregion

}
