//
//  ========================================================================
//  Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//


package org.eclipse.jetty.spdy.server.http;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;

import org.eclipse.jetty.spdy.api.Stream;
import org.eclipse.jetty.spdy.http.HTTPSPDYHeader;
import org.eclipse.jetty.util.Fields;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;

/**
 * <p>A SPDY push strategy that auto-populates push metadata based on referrer URLs.<p>A typical request for a main
 * resource such as {@code index.html} is immediately followed by a number of requests for associated resources.
 * Associated resource requests will have a {@code Referer} HTTP header that points to {@code index.html}, which is used
 * to link the associated resource to the main resource.<p>However, also following a hyperlink generates a HTTP request
 * with a {@code Referer} HTTP header that points to {@code index.html}; therefore a proper value for {@link
 * #setReferrerPushPeriod(int)} has to be set. If the referrerPushPeriod for a main resource has elapsed, no more
 * associated resources will be added for that main resource.<p>This class distinguishes associated main resources by
 * their URL path suffix and content type. CSS stylesheets, images and JavaScript files have recognizable URL path
 * suffixes that are classified as associated resources. The suffix regexs can be configured by constructor argument</p>
 * <p>When CSS stylesheets refer to images, the CSS image request will have the CSS stylesheet as referrer. This
 * implementation will push also the CSS image.<p>The push metadata built by this implementation is limited by the
 * number of pages of the application itself, and by the {@link #setMaxAssociatedResources(int)} max associated
 * resources} parameter. This parameter limits the number of associated resources per each main resource, so that if a
 * main resource has hundreds of associated resources, only up to the number specified by this parameter will be
 * pushed.
 */
public class ReferrerPushStrategy implements PushStrategy
{
    private static final Logger LOG = Log.getLogger(ReferrerPushStrategy.class);
    private final ConcurrentMap<String, MainResource> mainResources = new ConcurrentHashMap<>();
    private final Set<Pattern> pushRegexps = new HashSet<>();
    private final Set<String> pushContentTypes = new HashSet<>();
    private final Set<Pattern> allowedPushOrigins = new HashSet<>();
    private final Set<Pattern> userAgentBlacklist = new HashSet<>();
    private volatile int maxAssociatedResources = 32;
    private volatile int referrerPushPeriod = 5000;

    public ReferrerPushStrategy()
    {
        List<String> defaultPushRegexps = Arrays.asList(".*\\.css", ".*\\.js", ".*\\.png", ".*\\.jpeg", ".*\\.jpg",
                ".*\\.gif", ".*\\.ico");
        addPushRegexps(defaultPushRegexps);

        List<String> defaultPushContentTypes = Arrays.asList(
                "text/css",
                "text/javascript", "application/javascript", "application/x-javascript",
                "image/png", "image/x-png",
                "image/jpeg",
                "image/gif",
                "image/x-icon", "image/vnd.microsoft.icon");
        this.pushContentTypes.addAll(defaultPushContentTypes);
    }

    public void setPushRegexps(List<String> pushRegexps)
    {
        pushRegexps.clear();
        addPushRegexps(pushRegexps);
    }

    private void addPushRegexps(List<String> pushRegexps)
    {
        for (String pushRegexp : pushRegexps)
            this.pushRegexps.add(Pattern.compile(pushRegexp));
    }

    public void setPushContentTypes(List<String> pushContentTypes)
    {
        pushContentTypes.clear();
        pushContentTypes.addAll(pushContentTypes);
    }

    public void setAllowedPushOrigins(List<String> allowedPushOrigins)
    {
        allowedPushOrigins.clear();
        for (String allowedPushOrigin : allowedPushOrigins)
            this.allowedPushOrigins.add(Pattern.compile(allowedPushOrigin.replace(".", "\\.").replace("*", ".*")));
    }

    public void setUserAgentBlacklist(List<String> userAgentPatterns)
    {
        userAgentBlacklist.clear();
        for (String userAgentPattern : userAgentPatterns)
            userAgentBlacklist.add(Pattern.compile(userAgentPattern));
    }

    public void setMaxAssociatedResources(int maxAssociatedResources)
    {
        this.maxAssociatedResources = maxAssociatedResources;
    }

    public void setReferrerPushPeriod(int referrerPushPeriod)
    {
        this.referrerPushPeriod = referrerPushPeriod;
    }

    public Set<Pattern> getPushRegexps()
    {
        return pushRegexps;
    }

    public Set<String> getPushContentTypes()
    {
        return pushContentTypes;
    }

    public Set<Pattern> getAllowedPushOrigins()
    {
        return allowedPushOrigins;
    }

    public Set<Pattern> getUserAgentBlacklist()
    {
        return userAgentBlacklist;
    }

    public int getMaxAssociatedResources()
    {
        return maxAssociatedResources;
    }

    public int getReferrerPushPeriod()
    {
        return referrerPushPeriod;
    }

    @Override
    public Set<String> apply(Stream stream, Fields requestHeaders, Fields responseHeaders)
    {
        Set<String> result = Collections.<String>emptySet();
        short version = stream.getSession().getVersion();
        if (!isIfModifiedSinceHeaderPresent(requestHeaders) && isValidMethod(requestHeaders.get(HTTPSPDYHeader.METHOD
                .name(version)).getValue()) && !isUserAgentBlacklisted(requestHeaders))
        {
            String scheme = requestHeaders.get(HTTPSPDYHeader.SCHEME.name(version)).getValue();
            String host = requestHeaders.get(HTTPSPDYHeader.HOST.name(version)).getValue();
            String origin = scheme + "://" + host;
            String url = requestHeaders.get(HTTPSPDYHeader.URI.name(version)).getValue();
            String absoluteURL = origin + url;
            if (LOG.isDebugEnabled())
                LOG.debug("Applying push strategy for {}", absoluteURL);
            if (isMainResource(url, responseHeaders))
            {
                MainResource mainResource = getOrCreateMainResource(absoluteURL);
                result = mainResource.getResources();
            }
            else if (isPushResource(url, responseHeaders))
            {
                Fields.Field referrerHeader = requestHeaders.get("referer");
                if (referrerHeader != null)
                {
                    String referrer = referrerHeader.getValue();
                    MainResource mainResource = mainResources.get(referrer);
                    if (mainResource == null)
                        mainResource = getOrCreateMainResource(referrer);

                    Set<String> pushResources = mainResource.getResources();
                    if (!pushResources.contains(url))
                        mainResource.addResource(url, origin, referrer);
                    else
                        result = getPushResources(absoluteURL);
                }
            }
            if (LOG.isDebugEnabled())
                LOG.debug("Pushing {} resources for {}: {}", result.size(), absoluteURL, result);
        }
        return result;
    }

    private Set<String> getPushResources(String absoluteURL)
    {
        Set<String> result = Collections.emptySet();
        if (mainResources.get(absoluteURL) != null)
            result = mainResources.get(absoluteURL).getResources();
        return result;
    }

    private MainResource getOrCreateMainResource(String absoluteURL)
    {
        MainResource mainResource = mainResources.get(absoluteURL);
        if (mainResource == null)
        {
            if (LOG.isDebugEnabled())
                LOG.debug("Creating new main resource for {}", absoluteURL);
            MainResource value = new MainResource(absoluteURL);
            mainResource = mainResources.putIfAbsent(absoluteURL, value);
            if (mainResource == null)
                mainResource = value;
        }
        return mainResource;
    }

    private boolean isIfModifiedSinceHeaderPresent(Fields headers)
    {
        return headers.get("if-modified-since") != null;
    }

    private boolean isValidMethod(String method)
    {
        return "GET".equalsIgnoreCase(method);
    }

    private boolean isMainResource(String url, Fields responseHeaders)
    {
        return !isPushResource(url, responseHeaders);
    }

    public boolean isUserAgentBlacklisted(Fields headers)
    {
        Fields.Field userAgentHeader = headers.get("user-agent");
        if (userAgentHeader != null)
            for (Pattern userAgentPattern : userAgentBlacklist)
                if (userAgentPattern.matcher(userAgentHeader.getValue()).matches())
                    return true;
        return false;
    }

    private boolean isPushResource(String url, Fields responseHeaders)
    {
        for (Pattern pushRegexp : pushRegexps)
        {
            if (pushRegexp.matcher(url).matches())
            {
                Fields.Field header = responseHeaders.get("content-type");
                if (header == null)
                    return true;

                String contentType = header.getValue().toLowerCase(Locale.ENGLISH);
                for (String pushContentType : pushContentTypes)
                    if (contentType.startsWith(pushContentType))
                        return true;
            }
        }
        return false;
    }

    private class MainResource
    {
        private final String name;
        private final CopyOnWriteArraySet<String> resources = new CopyOnWriteArraySet<>();
        private final AtomicLong firstResourceAdded = new AtomicLong(-1);

        private MainResource(String name)
        {
            this.name = name;
        }

        public boolean addResource(String url, String origin, String referrer)
        {
            // We start the push period here and not when initializing the main resource, because a browser with a
            // prefilled cache won't request the subresources. If the browser with warmed up cache now hits the main
            // resource after a server restart, the push period shouldn't start until the first subresource is
            // being requested.
            firstResourceAdded.compareAndSet(-1, System.nanoTime());

            long delay = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - firstResourceAdded.get());
            if (!referrer.startsWith(origin) && !isPushOriginAllowed(origin))
            {
                if (LOG.isDebugEnabled())
                    LOG.debug("Skipped store of push metadata {} for {}: Origin: {} doesn't match or origin not allowed",
                        url, name, origin);
                return false;
            }

            // This check is not strictly concurrent-safe, but limiting
            // the number of associated resources is achieved anyway
            // although in rare cases few more resources will be stored
            if (resources.size() >= maxAssociatedResources)
            {
                if (LOG.isDebugEnabled())
                    LOG.debug("Skipped store of push metadata {} for {}: max associated resources ({}) reached",
                        url, name, maxAssociatedResources);
                return false;
            }
            if (delay > referrerPushPeriod)
            {
                if (LOG.isDebugEnabled())
                    LOG.debug("Delay: {}ms longer than referrerPushPeriod ({}ms). Not adding resource: {} for: {}",
                        delay, referrerPushPeriod, url, name);
                return false;
            }

            if (LOG.isDebugEnabled())
                LOG.debug("Adding: {} to: {} with delay: {}ms.", url, this, delay);
            resources.add(url);
            return true;
        }

        public Set<String> getResources()
        {
            return Collections.unmodifiableSet(resources);
        }

        public String toString()
        {
            return String.format("%s@%x{name=%s,resources=%s}",
                    getClass().getSimpleName(),
                    hashCode(),
                    name,
                    resources
            );
        }

        private boolean isPushOriginAllowed(String origin)
        {
            for (Pattern allowedPushOrigin : allowedPushOrigins)
                if (allowedPushOrigin.matcher(origin).matches())
                    return true;
            return false;
        }
    }
}
