/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.javascript.commonjs.module.provider;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.Serializable;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Iterator;
import java.util.List;


/**
 * A URL-based script provider that can load modules against a set of base
 * privileged and fallback URIs. It is deliberately not named "URI provider"
 * but a "URL provider" since it actually only works against those URIs that
 * are URLs (and the JRE has a protocol handler for them). It creates cache
 * validators that are suitable for use with both file: and http: URL
 * protocols. Specifically, it is able to use both last-modified timestamps and
 * ETags for cache revalidation, and follows the HTTP cache expiry calculation
 * model, and allows for fallback heuristic expiry calculation when no server
 * specified expiry is provided.
 * @author Attila Szegedi
 * @version $Id: UrlModuleSourceProvider.java,v 1.4 2011/04/07 20:26:12 hannes%helma.at Exp $
 */
public class UrlModuleSourceProvider extends ModuleSourceProviderBase
{
    private static final long serialVersionUID = 1L;

    private final Iterable<URI> privilegedUris;
    private final Iterable<URI> fallbackUris;
    private final UrlConnectionSecurityDomainProvider
        urlConnectionSecurityDomainProvider;
    private final UrlConnectionExpiryCalculator urlConnectionExpiryCalculator;

    /**
     * Creates a new module script provider that loads modules against a set of
     * privileged and fallback URIs. It will use a fixed default cache expiry
     * of 60 seconds, and provide no security domain objects for the resource.
     * @param privilegedUris an iterable providing the privileged URIs. Can be
     * null if no privileged URIs are used.
     * @param fallbackUris an iterable providing the fallback URIs. Can be
     * null if no fallback URIs are used.
     */
    public UrlModuleSourceProvider(Iterable<URI> privilegedUris,
            Iterable<URI> fallbackUris)
    {
        this(privilegedUris, fallbackUris,
                new DefaultUrlConnectionExpiryCalculator(), null);
    }

    /**
     * Creates a new module script provider that loads modules against a set of
     * privileged and fallback URIs. It will use the specified heuristic cache
     * expiry calculator and security domain provider.
     * @param privilegedUris an iterable providing the privileged URIs. Can be
     * null if no privileged URIs are used.
     * @param fallbackUris an iterable providing the fallback URIs. Can be
     * null if no fallback URIs are used.
     * @param urlConnectionExpiryCalculator the calculator object for heuristic
     * calculation of the resource expiry, used when no expiry is provided by
     * the server of the resource. Can be null, in which case the maximum age
     * of cached entries without validation will be zero.
     * @param urlConnectionSecurityDomainProvider object that provides security
     * domain objects for the loaded sources. Can be null, in which case the
     * loaded sources will have no security domain associated with them.
     */
    public UrlModuleSourceProvider(Iterable<URI> privilegedUris,
            Iterable<URI> fallbackUris,
            UrlConnectionExpiryCalculator urlConnectionExpiryCalculator,
            UrlConnectionSecurityDomainProvider urlConnectionSecurityDomainProvider)
    {
        this.privilegedUris = privilegedUris;
        this.fallbackUris = fallbackUris;
        this.urlConnectionExpiryCalculator = urlConnectionExpiryCalculator;
        this.urlConnectionSecurityDomainProvider =
            urlConnectionSecurityDomainProvider;
    }

    @Override
    protected ModuleSource loadFromPrivilegedLocations(
            String moduleId, Object validator)
            throws IOException, URISyntaxException
    {
        return loadFromPathList(moduleId, validator, privilegedUris);
    }

    @Override
    protected ModuleSource loadFromFallbackLocations(
            String moduleId, Object validator)
            throws IOException, URISyntaxException
    {
        return loadFromPathList(moduleId, validator, fallbackUris);
    }

    private ModuleSource loadFromPathList(String moduleId,
            Object validator, Iterable<URI> paths)
            throws IOException, URISyntaxException
    {
        if(paths == null) {
            return null;
        }
        for (URI path : paths) {
            final ModuleSource moduleSource = loadFromUri(
                    path.resolve(moduleId), path, validator);
            if (moduleSource != null) {
                return moduleSource;
            }
        }
        return null;
    }

    @Override
    protected ModuleSource loadFromUri(URI uri, URI base, Object validator)
    throws IOException, URISyntaxException
    {
        // We expect modules to have a ".js" file name extension ...
        URI fullUri = new URI(uri + ".js");
        ModuleSource source = loadFromActualUri(fullUri, base, validator);
        // ... but for compatibility we support modules without extension,
        // or ids with explicit extension.
        return source != null ?
               source : loadFromActualUri(uri, base, validator);
    }

    protected ModuleSource loadFromActualUri(URI uri, URI base, Object validator)
    throws IOException
    {
        final URL url = new URL(base == null ? null : base.toURL(), uri.toString());
        final long request_time = System.currentTimeMillis();
        final URLConnection urlConnection = openUrlConnection(url);
        final URLValidator applicableValidator;
        if(validator instanceof URLValidator) {
            final URLValidator uriValidator = ((URLValidator)validator);
            applicableValidator = uriValidator.appliesTo(uri) ? uriValidator :
                null;
        }
        else {
            applicableValidator = null;
        }
        if(applicableValidator != null) {
            applicableValidator.applyConditionals(urlConnection);
        }
        try {
            urlConnection.connect();
            if(applicableValidator != null &&
                    applicableValidator.updateValidator(urlConnection,
                            request_time, urlConnectionExpiryCalculator))
            {
                close(urlConnection);
                return NOT_MODIFIED;
            }

            return new ModuleSource(getReader(urlConnection),
                    getSecurityDomain(urlConnection), uri, base,
                    new URLValidator(uri, urlConnection, request_time,
                            urlConnectionExpiryCalculator));
        }
        catch(FileNotFoundException e) {
            return null;
        }
        catch(RuntimeException e) {
            close(urlConnection);
            throw e;
        }
        catch(IOException e) {
            close(urlConnection);
            throw e;
        }
    }

    private static Reader getReader(URLConnection urlConnection)
    throws IOException
    {
        return new InputStreamReader(urlConnection.getInputStream(),
                getCharacterEncoding(urlConnection));
    }

    private static String getCharacterEncoding(URLConnection urlConnection) {
        final ParsedContentType pct = new ParsedContentType(
                urlConnection.getContentType());
        final String encoding = pct.getEncoding();
        if(encoding != null) {
            return encoding;
        }
        final String contentType = pct.getContentType();
        if(contentType != null && contentType.startsWith("text/")) {
            return "8859_1";
        }
        else {
            return "utf-8";
        }
    }

    private Object getSecurityDomain(URLConnection urlConnection) {
        return urlConnectionSecurityDomainProvider == null ? null :
            urlConnectionSecurityDomainProvider.getSecurityDomain(
                    urlConnection);
    }

    private void close(URLConnection urlConnection) {
        try {
            urlConnection.getInputStream().close();
        }
        catch(IOException e) {
            onFailedClosingUrlConnection(urlConnection, e);
        }
    }

    /**
     * Override if you want to get notified if the URL connection fails to
     * close. Does nothing by default.
     * @param urlConnection the connection
     * @param cause the cause it failed to close.
     */
    protected void onFailedClosingUrlConnection(URLConnection urlConnection,
            IOException cause) {
    }

    /**
     * Can be overridden in subclasses to customize the URL connection opening
     * process. By default, just calls {@link URL#openConnection()}.
     * @param url the URL
     * @return a connection to the URL.
     * @throws IOException if an I/O error occurs.
     */
    protected URLConnection openUrlConnection(URL url) throws IOException {
        return url.openConnection();
    }

    @Override
    protected boolean entityNeedsRevalidation(Object validator) {
        return !(validator instanceof URLValidator)
                || ((URLValidator)validator).entityNeedsRevalidation();
    }

    private static class URLValidator implements Serializable {
        private static final long serialVersionUID = 1L;

        private final URI uri;
        private final long lastModified;
        private final String entityTags;
        private long expiry;

        public URLValidator(URI uri, URLConnection urlConnection,
                long request_time, UrlConnectionExpiryCalculator
                urlConnectionExpiryCalculator) {
            this.uri = uri;
            this.lastModified = urlConnection.getLastModified();
            this.entityTags = getEntityTags(urlConnection);
            expiry = calculateExpiry(urlConnection, request_time,
                    urlConnectionExpiryCalculator);
        }

        boolean updateValidator(URLConnection urlConnection, long request_time,
                UrlConnectionExpiryCalculator urlConnectionExpiryCalculator)
        throws IOException
        {
            boolean isResourceChanged = isResourceChanged(urlConnection);
            if(!isResourceChanged) {
                expiry = calculateExpiry(urlConnection, request_time,
                        urlConnectionExpiryCalculator);
            }
            return isResourceChanged;
        }

        private boolean isResourceChanged(URLConnection urlConnection)
        throws IOException {
            if(urlConnection instanceof HttpURLConnection) {
                return ((HttpURLConnection)urlConnection).getResponseCode() ==
                    HttpURLConnection.HTTP_NOT_MODIFIED;
            }
            return lastModified == urlConnection.getLastModified();
        }

        private long calculateExpiry(URLConnection urlConnection,
                long request_time, UrlConnectionExpiryCalculator
                urlConnectionExpiryCalculator)
        {
            if("no-cache".equals(urlConnection.getHeaderField("Pragma"))) {
                return 0L;
            }
            final String cacheControl = urlConnection.getHeaderField(
                    "Cache-Control");
            if(cacheControl != null ) {
                if(cacheControl.indexOf("no-cache") != -1) {
                    return 0L;
                }
                final int max_age = getMaxAge(cacheControl);
                if(-1 != max_age) {
                    final long response_time = System.currentTimeMillis();
                    final long apparent_age = Math.max(0, response_time -
                            urlConnection.getDate());
                    final long corrected_received_age = Math.max(apparent_age,
                            urlConnection.getHeaderFieldInt("Age", 0) * 1000L);
                    final long response_delay = response_time - request_time;
                    final long corrected_initial_age = corrected_received_age +
                        response_delay;
                    final long creation_time = response_time -
                        corrected_initial_age;
                    return max_age * 1000L + creation_time;
                }
            }
            final long explicitExpiry = urlConnection.getHeaderFieldDate(
                    "Expires", -1L);
            if(explicitExpiry != -1L) {
                return explicitExpiry;
            }
            return urlConnectionExpiryCalculator == null ? 0L :
                urlConnectionExpiryCalculator.calculateExpiry(urlConnection);
        }

        private int getMaxAge(String cacheControl) {
            final int maxAgeIndex = cacheControl.indexOf("max-age");
            if(maxAgeIndex == -1) {
                return -1;
            }
            final int eq = cacheControl.indexOf('=', maxAgeIndex + 7);
            if(eq == -1) {
                return -1;
            }
            final int comma = cacheControl.indexOf(',', eq + 1);
            final String strAge;
            if(comma == -1) {
                strAge = cacheControl.substring(eq + 1);
            }
            else {
                strAge = cacheControl.substring(eq + 1, comma);
            }
            try {
                return Integer.parseInt(strAge);
            }
            catch(NumberFormatException e) {
                return -1;
            }
        }

        private String getEntityTags(URLConnection urlConnection) {
            final List<String> etags = urlConnection.getHeaderFields().get("ETag");
            if(etags == null || etags.isEmpty()) {
                return null;
            }
            final StringBuilder b = new StringBuilder();
            final Iterator<String> it = etags.iterator();
            b.append(it.next());
            while(it.hasNext()) {
                b.append(", ").append(it.next());
            }
            return b.toString();
        }

        boolean appliesTo(URI uri) {
            return this.uri.equals(uri);
        }

        void applyConditionals(URLConnection urlConnection) {
            if(lastModified != 0L) {
                urlConnection.setIfModifiedSince(lastModified);
            }
            if(entityTags != null && entityTags.length() > 0) {
                urlConnection.addRequestProperty("If-None-Match", entityTags);
            }
        }

        boolean entityNeedsRevalidation() {
            return System.currentTimeMillis() > expiry;
        }
    }
}