package org.apereo.cas.adaptors.x509.authentication.revocation.checker;


import com.google.common.base.Throwables;
import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;
import org.apereo.cas.adaptors.x509.authentication.CRLFetcher;
import org.apereo.cas.adaptors.x509.authentication.ResourceCRLFetcher;
import org.apereo.cas.adaptors.x509.authentication.revocation.policy.RevocationPolicy;
import org.apereo.cas.adaptors.x509.util.CertUtils;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERIA5String;
import org.bouncycastle.asn1.x509.DistributionPoint;
import org.bouncycastle.asn1.x509.GeneralName;
import org.cryptacular.x509.ExtensionReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ByteArrayResource;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509CRL;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;

/**
 * Performs CRL-based revocation checking by consulting resources defined in
 * the CRLDistributionPoints extension field on the certificate.  Although RFC
 * 2459 allows the distribution point name to have arbitrary meaning, this class
 * expects the name to define an absolute URL, which is the most common
 * implementation.  This implementation caches CRL resources fetched from remote
 * URLs to improve performance by avoiding CRL fetching on every revocation
 * check.
 *
 * @author Marvin S. Addison
 * @since 3.4.6
 */
public class CRLDistributionPointRevocationChecker extends AbstractCRLRevocationChecker {
    private static final Logger LOGGER = LoggerFactory.getLogger(CRLDistributionPointRevocationChecker.class);
    
    private final Cache crlCache;
    private final CRLFetcher fetcher;
    private final boolean throwOnFetchFailure;

    /**
     * Creates a new instance that uses the given cache instance for CRL caching.
     *
     * @param crlCache Cache for CRL data.
     */
    public CRLDistributionPointRevocationChecker(final Cache crlCache) {
        this(crlCache, new ResourceCRLFetcher(), false);
    }

    /**
     * Creates a new instance that uses the given cache instance for CRL caching.
     *
     * @param crlCache            Cache for CRL data.
     * @param throwOnFetchFailure the throw on fetch failure
     */
    public CRLDistributionPointRevocationChecker(final Cache crlCache, final boolean throwOnFetchFailure) {
        this(crlCache, new ResourceCRLFetcher(), throwOnFetchFailure);
    }

    /**
     * Instantiates a new CRL distribution point revocation checker.
     *
     * @param crlCache            the crl cache
     * @param fetcher             the fetcher
     * @param throwOnFetchFailure the throw on fetch failure
     */
    public CRLDistributionPointRevocationChecker(
            final Cache crlCache, final CRLFetcher fetcher, final boolean throwOnFetchFailure) {
        this(false, null, null, crlCache, fetcher, throwOnFetchFailure);
    }

    public CRLDistributionPointRevocationChecker(final Cache crlCache,
                                                 final RevocationPolicy<Void> unavailableCRLPolicy) {
        this(crlCache, null, unavailableCRLPolicy);
    }

    public CRLDistributionPointRevocationChecker(final Cache crlCache,
                                                 final RevocationPolicy<X509CRL> expiredCRLPolicy,
                                                 final RevocationPolicy<Void> unavailableCRLPolicy) {
        this(crlCache, expiredCRLPolicy, unavailableCRLPolicy, false);
    }

    public CRLDistributionPointRevocationChecker(final Cache crlCache,
                                                 final RevocationPolicy<X509CRL> expiredCRLPolicy,
                                                 final RevocationPolicy<Void> unavailableCRLPolicy,
                                                 final boolean throwOnFetchFailure) {
        this(false, unavailableCRLPolicy, expiredCRLPolicy, crlCache, new ResourceCRLFetcher(), throwOnFetchFailure);
    }
    
    public CRLDistributionPointRevocationChecker(final boolean checkAll, final RevocationPolicy<Void> unavailableCRLPolicy,
                                                 final RevocationPolicy<X509CRL> expiredCRLPolicy, final Cache crlCache,
                                                 final CRLFetcher fetcher, final boolean throwOnFetchFailure) {
        super(checkAll, unavailableCRLPolicy, expiredCRLPolicy);
        this.crlCache = crlCache;
        this.fetcher = fetcher;
        this.throwOnFetchFailure = throwOnFetchFailure;
    }

    @Override
    protected List<X509CRL> getCRLs(final X509Certificate cert) {

        if (this.crlCache == null) {
            throw new IllegalArgumentException("CRL cache is not defined");
        }
        if (this.fetcher == null) {
            throw new IllegalArgumentException("CRL fetcher is not defined");
        }
        if (getExpiredCRLPolicy() == null) {
            throw new IllegalArgumentException("Expiration CRL policy is not defined");
        }
        if (getUnavailableCRLPolicy() == null) {
            throw new IllegalArgumentException("Unavailable CRL policy is not defined");
        }

        final URI[] urls = getDistributionPoints(cert);
        LOGGER.debug("Distribution points for [{}]: [{}].", CertUtils.toString(cert), Arrays.asList(urls));
        final List<X509CRL> listOfLocations = new ArrayList<>(urls.length);
        boolean stopFetching = false;

        try {
            for (int index = 0; !stopFetching && index < urls.length; index++) {
                final URI url = urls[index];
                final Element item = this.crlCache.get(url);

                if (item != null) {
                    LOGGER.debug("Found CRL in cache for [{}]", CertUtils.toString(cert));
                    final byte[] encodedCrl = (byte[]) item.getObjectValue();
                    final X509CRL crlFetched = this.fetcher.fetch(new ByteArrayResource(encodedCrl));

                    if (crlFetched != null) {
                        listOfLocations.add(crlFetched);
                    } else {
                        LOGGER.warn("Could fetch X509 CRL for [{}]. Returned value is null", url);
                    }
                } else {
                    LOGGER.debug("CRL for [{}] is not cached. Fetching and caching...", CertUtils.toString(cert));
                    try {
                        final X509CRL crl = this.fetcher.fetch(url);
                        if (crl != null) {
                            LOGGER.info("Success. Caching fetched CRL at [{}].", url);
                            addCRL(url, crl);
                            listOfLocations.add(crl);
                        }
                    } catch (final Exception e) {
                        LOGGER.error("Error fetching CRL at [{}]", url, e);
                        if (this.throwOnFetchFailure) {
                            throw Throwables.propagate(e);
                        }
                    }
                }

                if (!this.checkAll && !listOfLocations.isEmpty()) {
                    LOGGER.debug("CRL fetching is configured to not check all locations.");
                    stopFetching = true;
                }
            }
        } catch (final Exception e) {
            throw Throwables.propagate(e);
        }

        LOGGER.debug("Found [{}] CRLs", listOfLocations.size());
        return listOfLocations;
    }

    @Override
    protected boolean addCRL(final Object id, final X509CRL crl) {
        try {
            if (crl == null) {
                LOGGER.debug("No CRL was passed. Removing [{}] from cache...", id);
                return this.crlCache.remove(id);
            }

            this.crlCache.put(new Element(id, crl.getEncoded()));
            return this.crlCache.get(id) != null;

        } catch (final Exception e) {
            LOGGER.warn("Failed to add the crl entry [{}] to the cache", crl);
            throw Throwables.propagate(e);
        }
    }


    /**
     * Gets the distribution points.
     *
     * @param cert the cert
     * @return the url distribution points
     */
    private static URI[] getDistributionPoints(final X509Certificate cert) {
        final List<DistributionPoint> points;
        try {
            points = new ExtensionReader(cert).readCRLDistributionPoints();
        } catch (final RuntimeException e) {
            LOGGER.error("Error reading CRLDistributionPoints extension field on [{}]", CertUtils.toString(cert), e);
            return new URI[0];
        }

        final List<URI> urls = new ArrayList<>();

        if (points != null) {
            points.stream().map(DistributionPoint::getDistributionPoint).filter(Objects::nonNull).forEach(pointName -> {
                final ASN1Sequence nameSequence = ASN1Sequence.getInstance(pointName.getName());
                IntStream.range(0, nameSequence.size()).mapToObj(i -> GeneralName.getInstance(nameSequence.getObjectAt(i))).forEach(name -> {
                    LOGGER.debug("Found CRL distribution point [{}].", name);
                    try {
                        addURL(urls, DERIA5String.getInstance(name.getName()).getString());
                    } catch (final RuntimeException e) {
                        LOGGER.warn("[{}] not supported. String or GeneralNameList expected.", pointName);
                    }
                });
            });
        }

        return urls.toArray(new URI[urls.size()]);
    }

    /**
     * Adds the url to the list.
     * Build URI by components to facilitate proper encoding of querystring.
     * e.g. http://example.com:8085/ca?action=crl&issuer=CN=CAS Test User CA
     * <p>
     * <p>If {@code uriString} is encoded, it will be decoded with {@code UTF-8}
     * first before it's added to the list.</p>
     *
     * @param list      the list
     * @param uriString the uri string
     */
    private static void addURL(final List<URI> list, final String uriString) {
        try {
            URI uri;
            try {
                final URL url = new URL(URLDecoder.decode(uriString, StandardCharsets.UTF_8.name()));
                uri = new URI(url.getProtocol(), url.getAuthority(), url.getPath(), url.getQuery(), null);
            } catch (final MalformedURLException e) {
                uri = new URI(uriString);
            }
            list.add(uri);
        } catch (final Exception e) {
            LOGGER.warn("[{}] is not a valid distribution point URI.", uriString);
        }
    }


}
