package org.apereo.cas.web.flow.client;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apereo.cas.support.spnego.util.ReverseDNSRunnable;
import org.apereo.cas.web.support.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.webflow.action.AbstractAction;
import org.springframework.webflow.execution.Event;
import org.springframework.webflow.execution.RequestContext;

import javax.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Abstract class for defining a simple binary filter to determine whether a
 * given client system should be prompted for SPNEGO / KRB / NTLM credentials.
 *
 * Envisioned implementations would include LDAP and DNS based determinations,
 * but of course others may have value as well for local architectures.
 *
 * @author Sean Baker sean.baker@usuhs.edu
 * @author Misagh Moayyed
 * @since 4.1
 */
public class BaseSpnegoKnownClientSystemsFilterAction extends AbstractAction {

    private static final Logger LOGGER = LoggerFactory.getLogger(BaseSpnegoKnownClientSystemsFilterAction.class);
    
    /** Pattern of ip addresses to check. **/
    private Pattern ipsToCheckPattern;

    /** Alternative remote host attribute. **/
    private String alternativeRemoteHostAttribute;

    /**
     * Set timeout (ms) for DNS requests; valuable for heterogeneous environments employing
     * fall-through authentication mechanisms.
     */
    private long timeout;
    
    /**
     * Instantiates a new Base.
     *
     * @param ipsToCheckPattern the ips to check pattern
     */
    public BaseSpnegoKnownClientSystemsFilterAction(final String ipsToCheckPattern) {
        setIpsToCheckPattern(ipsToCheckPattern);
    }

    /**
     * Instantiates a new Base.
     *
     * @param ipsToCheckPattern the ips to check pattern
     * @param alternativeRemoteHostAttribute the alternative remote host attribute
     * @param dnsTimeout # of milliseconds to wait for a DNS request to return
     */
    public BaseSpnegoKnownClientSystemsFilterAction(final String ipsToCheckPattern, final String alternativeRemoteHostAttribute, final long dnsTimeout) {
        setIpsToCheckPattern(ipsToCheckPattern);
        this.alternativeRemoteHostAttribute = alternativeRemoteHostAttribute;
        this.timeout = dnsTimeout;
    }

    /**
     * {@inheritDoc}
     * Gets the remote ip from the request, and invokes spnego if it isn't filtered.
     *
     * @param context the request context
     * @return {@link #yes()} if spnego should be invoked and ip isn't filtered,
     * {@link #no()} otherwise.
     */
    @Override
    protected Event doExecute(final RequestContext context) {
        final String remoteIp = getRemoteIp(context);
        LOGGER.debug("Current user IP [{}]", remoteIp);
        if (shouldDoSpnego(remoteIp)) {
            LOGGER.info("Spnego should be activated for [{}]", remoteIp);
            return yes();
        }
        LOGGER.info("Spnego should is skipped for [{}]", remoteIp);
        return no();
    }

    /**
     * Default implementation -- simply check the IP filter.
     * @param remoteIp the remote ip
     * @return true boolean
     */
    protected boolean shouldDoSpnego(final String remoteIp) {
        return ipPatternCanBeChecked(remoteIp) && ipPatternMatches(remoteIp);
    }

    /**
     * Base class definition for whether the IP should be checked or not; overridable.
     * @param remoteIp the remote ip
     * @return whether or not the IP can / should be matched against the pattern
     */
    protected boolean ipPatternCanBeChecked(final String remoteIp) {
        return this.ipsToCheckPattern != null && StringUtils.isNotBlank(remoteIp);
    }

    /**
     * Simple pattern match to determine whether an IP should be checked.
     * Could stand to be extended to support "real" IP addresses and patterns, but
     * for the local / first implementation regex made more sense.
     * @param remoteIp the remote ip
     * @return whether the remote ip received should be queried
     */
    protected boolean ipPatternMatches(final String remoteIp) {
        final Matcher matcher = this.ipsToCheckPattern.matcher(remoteIp);
        if (matcher.find()) {
            LOGGER.debug("Remote IP address [{}] should be checked based on the defined pattern [{}]",
                    remoteIp, this.ipsToCheckPattern.pattern());
            return true;
        }
        LOGGER.debug("No pattern or remote IP defined, or pattern does not match remote IP [{}]",
                remoteIp);
        return false;
    }

    /**
     * Pulls the remote IP from the current HttpServletRequest, or grabs the value
     * for the specified alternative attribute (say, for proxied requests).  Falls
     * back to providing the "normal" remote address if no value can be retrieved
     * from the specified alternative header value.
     * @param context the context
     * @return the remote ip
     */
    private String getRemoteIp(final RequestContext context) {
        final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
        String userAddress = request.getRemoteAddr();
        LOGGER.debug("Remote Address = [{}]", userAddress);

        if (StringUtils.isNotBlank(this.alternativeRemoteHostAttribute)) {

            userAddress = request.getHeader(this.alternativeRemoteHostAttribute);
            LOGGER.debug("Header Attribute [{}] = [{}]", this.alternativeRemoteHostAttribute, userAddress);

            if (StringUtils.isBlank(userAddress)) {
                userAddress = request.getRemoteAddr();
                LOGGER.warn("No value could be retrieved from the header [{}]. Falling back to [{}].",
                        this.alternativeRemoteHostAttribute, userAddress);
            }
        }
        return userAddress;
    }

    /**
     * Alternative header to be used for retrieving the remote system IP address.
     * @param alternativeRemoteHostAttribute the alternative remote host attribute
     */
    public void setAlternativeRemoteHostAttribute(final String alternativeRemoteHostAttribute) {
        this.alternativeRemoteHostAttribute = alternativeRemoteHostAttribute;
    }

    /**
     * Regular expression string to define IPs which should be considered.
     * @param ipsToCheckPattern the ips to check as a regex pattern
     */
    public void setIpsToCheckPattern(final String ipsToCheckPattern) {
        this.ipsToCheckPattern = Pattern.compile(ipsToCheckPattern);
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this)
                .append("ipsToCheckPattern", this.ipsToCheckPattern)
                .append("alternativeRemoteHostAttribute", this.alternativeRemoteHostAttribute)
                .append("timeout", this.timeout)
                .toString();
    }

    /**
     * Convenience method to perform a reverse DNS lookup. Threads the request
     * through a custom Runnable class in order to prevent inordinately long
     * user waits while performing reverse lookup.
     * @param remoteIp the remote ip
     * @return the remote host name
     */
    protected String getRemoteHostName(final String remoteIp) {
        final ReverseDNSRunnable revDNS = new ReverseDNSRunnable(remoteIp);

        final Thread t = new Thread(revDNS);
        t.start();

        try {
            t.join(this.timeout);
        } catch (final InterruptedException e) {
            LOGGER.debug("Threaded lookup failed.  Defaulting to IP [{}].", remoteIp, e);
        }

        final String remoteHostName = revDNS.get();
        LOGGER.debug("Found remote host name [{}].", remoteHostName);

        return StringUtils.isNotBlank(remoteHostName) ? remoteHostName : remoteIp;
    }
}
