package org.apereo.cas.authentication;


import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apereo.cas.services.MultifactorAuthenticationProvider;
import org.apereo.cas.services.MultifactorAuthenticationProviderBypass;
import org.apereo.cas.services.RegisteredService;
import org.apereo.cas.services.RegisteredServiceMultifactorPolicy;
import org.apereo.cas.util.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.OrderComparator;

import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;

/**
 * The {@link DefaultAuthenticationContextValidator} is responsible for evaluating an authentication
 * object to see whether it satisfied a requested authentication context.
 *
 * @author Misagh Moayyed
 * @since 4.3
 */
public class DefaultAuthenticationContextValidator implements AuthenticationContextValidator {

    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAuthenticationContextValidator.class);

    private final String authenticationContextAttribute;
    private final String globalFailureMode;
    private final String mfaTrustedAuthnAttributeName;

    @Autowired
    private ConfigurableApplicationContext applicationContext;

    public DefaultAuthenticationContextValidator(final String contextAttribute, final String failureMode, final String authnAttributeName) {
        this.authenticationContextAttribute = contextAttribute;
        this.globalFailureMode = failureMode;
        this.mfaTrustedAuthnAttributeName = authnAttributeName;
    }

    public String getAuthenticationContextAttribute() {
        return this.authenticationContextAttribute;
    }

    /**
     * {@inheritDoc}
     * If the authentication event is established as part trusted/device browser
     * such that MFA was skipped, allow for validation to execute successfully.
     * If authentication event did bypass MFA, let for allow for validation to execute successfully.
     *
     * @param authentication   the authentication
     * @param requestedContext the requested context
     * @param service          the service
     * @return true if the context can be successfully validated.
     */
    @Override
    public Pair<Boolean, Optional<MultifactorAuthenticationProvider>> validate(final Authentication authentication,
                                                                               final String requestedContext,
                                                                               final RegisteredService service) {
        final Map<String, Object> attrs = authentication.getAttributes();
        final Object ctxAttr = attrs.get(this.authenticationContextAttribute);
        final Collection<Object> contexts = CollectionUtils.toCollection(ctxAttr);
        LOGGER.debug("Attempting to match requested authentication context [{}] against [{}]", requestedContext, contexts);

        final Map<String, MultifactorAuthenticationProvider> providerMap =
                getAllMultifactorAuthenticationProvidersFromApplicationContext();
        if (providerMap == null) {
            LOGGER.debug("No providers have been configured");
            return Pair.of(Boolean.FALSE, Optional.empty());
        }
        final Optional<MultifactorAuthenticationProvider> requestedProvider =
                locateRequestedProvider(providerMap.values(), requestedContext);

        if (!requestedProvider.isPresent()) {
            LOGGER.debug("Requested authentication provider cannot be recognized.");
            return Pair.of(Boolean.FALSE, Optional.empty());
        }

        if (contexts.stream().filter(ctx -> ctx.toString().equals(requestedContext)).count() > 0) {
            LOGGER.debug("Requested authentication context [{}] is satisfied", requestedContext);
            return Pair.of(Boolean.TRUE, requestedProvider);
        }


        if (StringUtils.isNotBlank(this.mfaTrustedAuthnAttributeName)
                && attrs.containsKey(this.mfaTrustedAuthnAttributeName)) {
            LOGGER.debug("Requested authentication context [{}] is satisfied since device is already trusted", requestedContext);
            return Pair.of(Boolean.TRUE, requestedProvider);
        }

        if (attrs.containsKey(MultifactorAuthenticationProviderBypass.AUTHENTICATION_ATTRIBUTE_BYPASS_MFA)
                && attrs.containsKey(MultifactorAuthenticationProviderBypass.AUTHENTICATION_ATTRIBUTE_BYPASS_MFA_PROVIDER)) {

            final boolean isBypass = Boolean.class.cast(attrs.get(MultifactorAuthenticationProviderBypass.AUTHENTICATION_ATTRIBUTE_BYPASS_MFA));
            final String bypassedId = attrs.get(MultifactorAuthenticationProviderBypass.AUTHENTICATION_ATTRIBUTE_BYPASS_MFA_PROVIDER).toString();

            LOGGER.debug("Found multifactor authentication bypass attributes for provider [{}]", bypassedId);

            if (isBypass && StringUtils.equals(bypassedId, requestedContext)) {
                LOGGER.debug("Requested authentication context [{}] is satisfied given mfa was bypassed for the authentication attempt", 
                        requestedContext);
                return Pair.of(Boolean.TRUE, requestedProvider);
            }

            LOGGER.debug("Either multifactor authentication was not bypassed or the requested context [{}] does not match the bypassed provider [{}]",
                    requestedProvider, bypassedId);
        }

        final Collection<MultifactorAuthenticationProvider> satisfiedProviders =
                getSatisfiedAuthenticationProviders(authentication, providerMap.values());

        if (satisfiedProviders == null) {
            LOGGER.warn("No satisfied multifactor authentication providers are recorded in the current authentication context.");
            return Pair.of(Boolean.FALSE, requestedProvider);
        }

        if (!satisfiedProviders.isEmpty()) {
            final MultifactorAuthenticationProvider[] providers = satisfiedProviders.toArray(new MultifactorAuthenticationProvider[]{});
            OrderComparator.sortIfNecessary(providers);
            final Optional<MultifactorAuthenticationProvider> result = Arrays.stream(providers)
                    .filter(provider -> {
                        final MultifactorAuthenticationProvider p = requestedProvider.get();
                        return provider.equals(p) || provider.getOrder() >= p.getOrder();
                    })
                    .findFirst();

            if (result.isPresent()) {
                LOGGER.debug("Current provider [{}] already satisfies the authentication requirements of [{}]; proceed with flow normally.",
                        result.get(), requestedProvider);
                return Pair.of(Boolean.TRUE, requestedProvider);
            }
        }

        LOGGER.debug("No multifactor providers could be located to satisfy the requested context for [{}]", requestedProvider);

        final RegisteredServiceMultifactorPolicy.FailureModes mode = getMultifactorFailureModeForService(service);
        if (mode == RegisteredServiceMultifactorPolicy.FailureModes.PHANTOM) {
            if (!requestedProvider.get().isAvailable(service)) {
                LOGGER.debug("Service [{}] is configured to use a [{}] failure mode for multifactor authentication policy. "
                                + "Since provider [{}] is unavailable at the moment, CAS will knowingly allow [{}] as a satisfied criteria "
                                + "of the present authentication context", service.getServiceId(),
                        mode, requestedProvider, requestedContext);
                return Pair.of(true, requestedProvider);
            }
        }
        if (mode == RegisteredServiceMultifactorPolicy.FailureModes.OPEN) {
            if (!requestedProvider.get().isAvailable(service)) {
                LOGGER.debug("Service [{}] is configured to use a [{}] failure mode for multifactor authentication policy and "
                                + "since provider [{}] is unavailable at the moment, CAS will consider the authentication satisfied "
                                + "without the presence of [{}]", service.getServiceId(),
                        mode, requestedProvider, requestedContext);
                return Pair.of(true, satisfiedProviders.stream().findFirst());
            }
        }

        return Pair.of(false, requestedProvider);
    }

    /**
     * Gets all multifactor authentication providers from application context.
     *
     * @return the all multifactor authentication providers from application context
     */
    private Map<String, MultifactorAuthenticationProvider> getAllMultifactorAuthenticationProvidersFromApplicationContext() {
        try {
            return this.applicationContext.getBeansOfType(MultifactorAuthenticationProvider.class, false, true);
        } catch (final Exception e) {
            LOGGER.warn("Could not locate beans of type [{}] in the application context", MultifactorAuthenticationProvider.class);
        }
        return null;
    }

    private Collection<MultifactorAuthenticationProvider> getSatisfiedAuthenticationProviders(final Authentication authentication,
            final Collection<MultifactorAuthenticationProvider> providers) {
        final Collection<Object> contexts = CollectionUtils.toCollection(
                authentication.getAttributes().get(this.authenticationContextAttribute));

        if (contexts == null || contexts.isEmpty()) {
            LOGGER.debug("No authentication context could be determined based on authentication attribute [{}]",
                    this.authenticationContextAttribute);
            return null;
        }

        contexts.stream().forEach(context ->
                providers.removeIf(provider -> !provider.getId().equals(context))
        );

        LOGGER.debug("Found [{}] providers that may satisfy the context", providers.size());
        return providers;
    }


    private static Optional<MultifactorAuthenticationProvider> locateRequestedProvider(final Collection<MultifactorAuthenticationProvider> providersArray,
                                                                                       final String requestedProvider) {
        return providersArray.stream()
                .filter(provider -> provider.getId().equals(requestedProvider))
                .findFirst();
    }

    private RegisteredServiceMultifactorPolicy.FailureModes getMultifactorFailureModeForService(final RegisteredService service) {
        final RegisteredServiceMultifactorPolicy policy = service.getMultifactorPolicy();
        if (policy == null || policy.getFailureMode() == null) {
            return RegisteredServiceMultifactorPolicy.FailureModes.valueOf(this.globalFailureMode);
        }
        return policy.getFailureMode();
    }
}
