package org.apereo.cas.services;

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apereo.cas.util.CollectionUtils;
import org.apereo.cas.util.RegexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * This is {@link DefaultRegisteredServiceAccessStrategy}
 * that allows the following rules:
 *
 * <ul>
 *     <li>A service may be disallowed to use CAS for authentication</li>
 *     <li>A service may be disallowed to take part in CAS single sign-on such that
 *     presentation of credentials would always be required.</li>
 *     <li>A service may be prohibited from receiving a service ticket
 *     if the existing principal attributes don't contain the required attributes
 *     that otherwise grant access to the service.</li>
 * </ul>
 *
 * @author Misagh Moayyed mmoayyed@unicon.net
 * @since 4.1
 */
public class DefaultRegisteredServiceAccessStrategy implements RegisteredServiceAccessStrategy {

    private static final long serialVersionUID = 1245279151345635245L;

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

    /** Is the service allowed at all? **/
    private boolean enabled = true;

    /** Is the service allowed to use SSO? **/
    private boolean ssoEnabled = true;

    private URI unauthorizedRedirectUrl;

    /**
     * Defines the attribute aggregation behavior when checking for required attributes.
     * Default requires that all attributes be present and match the principal's.
     */
    private boolean requireAllAttributes = true;

    /**
     * Collection of required attributes
     * for this service to proceed.
     */
    private Map<String, Set<String>> requiredAttributes = new HashMap<>();

    /**
     * Collection of attributes
     * that will be rejected which will cause this
     * policy to refuse access.
     */
    private Map<String, Set<String>> rejectedAttributes = new HashMap<>();
    
    /**
     * Indicates whether matching on required attribute values
     * should be done in a case-insensitive manner.
     */
    private boolean caseInsensitive;

    /**
     * Instantiates a new Default registered service authorization strategy.
     * By default, rules indicate that services are both enabled
     * and can participate in SSO.
     */
    public DefaultRegisteredServiceAccessStrategy() {
        this(true, true);
    }

    /**
     * Instantiates a new Default registered service authorization strategy.
     *
     * @param enabled the enabled
     * @param ssoEnabled the sso enabled
     */
    public DefaultRegisteredServiceAccessStrategy(final boolean enabled, final boolean ssoEnabled) {
        this.enabled = enabled;
        this.ssoEnabled = ssoEnabled;
    }

    public void setEnabled(final boolean enabled) {
        this.enabled = enabled;
    }

    /**
     * Set to enable/authorize this service.
     * @param ssoEnabled true to enable service
     */
    public void setSsoEnabled(final boolean ssoEnabled) {
        this.ssoEnabled = ssoEnabled;
    }

    public boolean isEnabled() {
        return this.enabled;
    }

    public boolean isSsoEnabled() {
        return this.ssoEnabled;
    }

    /**
     * Defines the attribute aggregation when checking for required attributes.
     * Default requires that all attributes be present and match the principal's.
     * @param requireAllAttributes the require all attributes
     */
    public void setRequireAllAttributes(final boolean requireAllAttributes) {
        this.requireAllAttributes = requireAllAttributes;
    }

    public boolean isRequireAllAttributes() {
        return this.requireAllAttributes;
    }

    public Map<String, Set<String>> getRequiredAttributes() {
        return new HashMap<>(this.requiredAttributes);
    }

    public void setUnauthorizedRedirectUrl(final URI unauthorizedRedirectUrl) {
        this.unauthorizedRedirectUrl = unauthorizedRedirectUrl;
    }

    @Override
    public URI getUnauthorizedRedirectUrl() {
        return this.unauthorizedRedirectUrl;
    }

    /**
     * Is attribute value matching case insensitive?
     *
     * @return true/false
     */
    public boolean isCaseInsensitive() {
        return this.caseInsensitive;
    }

    /**
     * Sets case insensitive.
     *
     * @param caseInsensitive the case insensitive
     * @since 5.0.0
     */
    public void setCaseInsensitive(final boolean caseInsensitive) {
        this.caseInsensitive = caseInsensitive;
    }

    /**
     * Defines the required attribute names and values that
     * must be available to the principal before the flow
     * can proceed to the next step. Every attribute in
     * the map can be linked to multiple values.
     *
     * @param requiredAttributes the required attributes
     */
    public void setRequiredAttributes(final Map<String, Set<String>> requiredAttributes) {
        this.requiredAttributes = requiredAttributes;
    }

    /**
     * Sets rejected attributes. If the policy finds any of the attributes defined
     * here, it will simply reject and refuse access. 
     *
     * @param rejectedAttributes the rejected attributes
     */
    public void setRejectedAttributes(final Map<String, Set<String>> rejectedAttributes) {
        this.rejectedAttributes = rejectedAttributes;
    }

    public Map<String, Set<String>> getRejectedAttributes() {
        return this.rejectedAttributes;
    }

    /**
     * {@inheritDoc}
     *
     * Verify presence of service required attributes.
     * <ul>
     *     <li>If no rejected attributes are specified, authz is granted.</li>
     *     <li>If no required attributes are specified, authz is granted.</li>
     *     <li>If ALL attributes must be present, and the principal contains all and there is
     *     at least one attribute value that matches the rejected, authz is denied.</li>
     *     <li>If ALL attributes must be present, and the principal contains all and there is
     *     at least one attribute value that matches the required, authz is granted.</li>
     *     <li>If ALL attributes don't have to be present, and there is at least
     *     one principal attribute present whose value matches the rejected, authz is denied.</li>
     *     <li>If ALL attributes don't have to be present, and there is at least
     *     one principal attribute present whose value matches the required, authz is granted.</li>
     *     <li>Otherwise, access is denied</li>
     * </ul>
     */
    @Override
    public boolean doPrincipalAttributesAllowServiceAccess(final String principal, final Map<String, Object> principalAttributes) {
        if (this.rejectedAttributes.isEmpty() && this.requiredAttributes.isEmpty()) {
            LOGGER.debug("Skipping access strategy policy, since no attributes rules are defined");
            return true;
        }
        
        if (!enoughAttributesAvailableToProcess(principal, principalAttributes)) {
            LOGGER.debug("Access is denied. There are not enough attributes available to satisfy requirements");
            return false;
        }

        if (doRejectedAttributesRefusePrincipalAccess(principalAttributes)) {
            LOGGER.debug("Access is denied. The principal carries attributes that would reject service access");
            return false;
        }
                
        if (!doRequiredAttributesAllowPrincipalAccess(principalAttributes)) {
            LOGGER.debug("Access is denied. The principal does not have the required attributes specified by this strategy");
            return false;
        }

        return true;
    }

    private boolean doRequiredAttributesAllowPrincipalAccess(final Map<String, Object> principalAttributes) {
        LOGGER.debug("These required attributes [{}] are examined against [{}] before service can proceed.", requiredAttributes, principalAttributes);
        if (requiredAttributes.isEmpty()) {
            return true;
        }

        return common(principalAttributes, requiredAttributes);
    }
    
    private boolean doRejectedAttributesRefusePrincipalAccess(final Map<String, Object> principalAttributes) {
        LOGGER.debug("These rejected attributes [{}] are examined against [{}] before service can proceed.", rejectedAttributes, principalAttributes);
        if (rejectedAttributes.isEmpty()) {
            return false;
        }
        return common(principalAttributes, rejectedAttributes);
    }

    /**
     * Enough attributes available to process? Check collection sizes and determine
     * if we have enough data to move on.
     *
     * @param principal           the principal
     * @param principalAttributes the principal attributes
     * @return true/false
     */
    protected boolean enoughAttributesAvailableToProcess(final String principal, final Map<String, Object> principalAttributes) {
        if (principalAttributes.isEmpty() && !this.requiredAttributes.isEmpty()) {
            LOGGER.debug("No principal attributes are found to satisfy defined attribute requirements");
            return false;
        }

        if (principalAttributes.size() < this.rejectedAttributes.size()) {
            LOGGER.debug("The size of the principal attributes that are [{}] does not match defined rejected attributes, "
                            + "which means the principal is not carrying enough data to grant authorization",
                    principalAttributes);
            return false;
        }

        if (principalAttributes.size() < this.requiredAttributes.size()) {
            LOGGER.debug("The size of the principal attributes that are [{}] does not match defined required attributes, "
                        + "which indicates the principal is not carrying enough data to grant authorization",
                    principalAttributes);
            return false;
        }
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isServiceAccessAllowedForSso() {
        if (!this.ssoEnabled) {
            LOGGER.trace("Service is not authorized to participate in SSO.");
        }
        return this.ssoEnabled;
    }

    @JsonIgnore
    @Override
    public boolean isServiceAccessAllowed() {
        if (!this.enabled) {
            LOGGER.trace("Service is not enabled in service registry.");
        }

        return this.enabled;
    }

    @Override
    public boolean equals(final Object obj) {
        if (obj == null) {
            return false;
        }
        if (obj == this) {
            return true;
        }
        if (obj.getClass() != getClass()) {
            return false;
        }
        final DefaultRegisteredServiceAccessStrategy rhs = (DefaultRegisteredServiceAccessStrategy) obj;
        return new EqualsBuilder()
                .append(this.enabled, rhs.enabled)
                .append(this.ssoEnabled, rhs.ssoEnabled)
                .append(this.requireAllAttributes, rhs.requireAllAttributes)
                .append(this.requiredAttributes, rhs.requiredAttributes)
                .append(this.unauthorizedRedirectUrl, rhs.unauthorizedRedirectUrl)
                .append(this.caseInsensitive, rhs.caseInsensitive)
                .append(this.rejectedAttributes, rhs.rejectedAttributes)
                .isEquals();
    }

    @Override
    public int hashCode() {
        return new HashCodeBuilder()
                .append(this.enabled)
                .append(this.ssoEnabled)
                .append(this.requireAllAttributes)
                .append(this.requiredAttributes)
                .append(this.unauthorizedRedirectUrl)
                .append(this.caseInsensitive)
                .append(this.rejectedAttributes)
                .toHashCode();
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this)
                .append("enabled", this.enabled)
                .append("ssoEnabled", this.ssoEnabled)
                .append("requireAllAttributes", this.requireAllAttributes)
                .append("requiredAttributes", this.requiredAttributes)
                .append("unauthorizedRedirectUrl", this.unauthorizedRedirectUrl)
                .append("caseInsensitive", this.caseInsensitive)
                .append("rejectedAttributes", this.rejectedAttributes)
                .toString();
    }

    private boolean common(final Map<String, Object> principalAttributes, final Map<String, Set<String>> attributes) {
        final Set<String> difference = attributes.keySet().stream()
                .filter(a -> principalAttributes.keySet().contains(a))
                .collect(Collectors.toSet());

        if (this.requireAllAttributes && difference.size() < attributes.size()) {
            return false;
        }

        return difference.stream().anyMatch(key -> {
            final Set<String> values = attributes.get(key);
            final Set<Object> availableValues = CollectionUtils.toCollection(principalAttributes.get(key));

            final Pattern pattern = RegexUtils.concatenate(values, this.caseInsensitive);
            if (pattern != RegexUtils.MATCH_NOTHING_PATTERN) {
                return availableValues.stream().map(Object::toString).anyMatch(pattern.asPredicate());
            }
            return availableValues.stream().anyMatch(values::contains);
        });
    }
}
