/* 
 * Copyright 2011 Vaadin Ltd.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.vaadin.client;

import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.RepeatingCommand;
import com.google.gwt.dom.client.AnchorElement;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.LinkElement;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.ObjectElement;
import com.google.gwt.dom.client.ScriptElement;
import com.google.gwt.user.client.Timer;

/**
 * ResourceLoader lets you dynamically include external scripts and styles on
 * the page and lets you know when the resource has been loaded.
 * 
 * You can also preload resources, allowing them to get cached by the browser
 * without being evaluated. This enables downloading multiple resources at once
 * while still controlling in which order e.g. scripts are executed.
 * 
 * @author Vaadin Ltd
 * @since 7.0.0
 */
public class ResourceLoader {
    /**
     * Event fired when a resource has been loaded.
     */
    public static class ResourceLoadEvent {
        private ResourceLoader loader;
        private String resourceUrl;
        private final boolean preload;

        /**
         * Creates a new event.
         * 
         * @param loader
         *            the resource loader that has loaded the resource
         * @param resourceUrl
         *            the url of the loaded resource
         * @param preload
         *            true if the resource has only been preloaded, false if
         *            it's fully loaded
         */
        public ResourceLoadEvent(ResourceLoader loader, String resourceUrl,
                boolean preload) {
            this.loader = loader;
            this.resourceUrl = resourceUrl;
            this.preload = preload;
        }

        /**
         * Gets the resource loader that has fired this event
         * 
         * @return the resource loader
         */
        public ResourceLoader getResourceLoader() {
            return loader;
        }

        /**
         * Gets the absolute url of the loaded resource.
         * 
         * @return the absolute url of the loaded resource
         */
        public String getResourceUrl() {
            return resourceUrl;
        }

        /**
         * Returns true if the resource has been preloaded, false if it's fully
         * loaded
         * 
         * @see ResourceLoader#preloadResource(String, ResourceLoadListener)
         * 
         * @return true if the resource has been preloaded, false if it's fully
         *         loaded
         */
        public boolean isPreload() {
            return preload;
        }
    }

    /**
     * Event listener that gets notified when a resource has been loaded
     */
    public interface ResourceLoadListener {
        /**
         * Notifies this ResourceLoadListener that a resource has been loaded.
         * Some browsers do not support any way of detecting load errors. In
         * these cases, onLoad will be called regardless of the status.
         * 
         * @see ResourceLoadEvent
         * 
         * @param event
         *            a resource load event with information about the loaded
         *            resource
         */
        public void onLoad(ResourceLoadEvent event);

        /**
         * Notifies this ResourceLoadListener that a resource could not be
         * loaded, e.g. because the file could not be found or because the
         * server did not respond. Some browsers do not support any way of
         * detecting load errors. In these cases, onLoad will be called
         * regardless of the status.
         * 
         * @see ResourceLoadEvent
         * 
         * @param event
         *            a resource load event with information about the resource
         *            that could not be loaded.
         */
        public void onError(ResourceLoadEvent event);
    }

    private static final ResourceLoader INSTANCE = GWT
            .create(ResourceLoader.class);

    private ApplicationConnection connection;

    private final Set<String> loadedResources = new HashSet<String>();
    private final Set<String> preloadedResources = new HashSet<String>();

    private final Map<String, Collection<ResourceLoadListener>> loadListeners = new HashMap<String, Collection<ResourceLoadListener>>();
    private final Map<String, Collection<ResourceLoadListener>> preloadListeners = new HashMap<String, Collection<ResourceLoadListener>>();

    private final Element head;

    /**
     * Creates a new resource loader. You should generally not create you own
     * resource loader, but instead use {@link ResourceLoader#get()} to get an
     * instance.
     */
    protected ResourceLoader() {
        Document document = Document.get();
        head = document.getElementsByTagName("head").getItem(0);

        // detect already loaded scripts and stylesheets
        NodeList<Element> scripts = document.getElementsByTagName("script");
        for (int i = 0; i < scripts.getLength(); i++) {
            ScriptElement element = ScriptElement.as(scripts.getItem(i));
            String src = element.getSrc();
            if (src != null && src.length() != 0) {
                loadedResources.add(src);
            }
        }

        NodeList<Element> links = document.getElementsByTagName("link");
        for (int i = 0; i < links.getLength(); i++) {
            LinkElement linkElement = LinkElement.as(links.getItem(i));
            String rel = linkElement.getRel();
            String href = linkElement.getHref();
            if ("stylesheet".equalsIgnoreCase(rel) && href != null
                    && href.length() != 0) {
                loadedResources.add(href);
            }
        }
    }

    /**
     * Returns the default ResourceLoader
     * 
     * @return the default ResourceLoader
     */
    public static ResourceLoader get() {
        return INSTANCE;
    }

    /**
     * Load a script and notify a listener when the script is loaded. Calling
     * this method when the script is currently loading or already loaded
     * doesn't cause the script to be loaded again, but the listener will still
     * be notified when appropriate.
     * 
     * 
     * @param scriptUrl
     *            the url of the script to load
     * @param resourceLoadListener
     *            the listener that will get notified when the script is loaded
     */
    public void loadScript(final String scriptUrl,
            final ResourceLoadListener resourceLoadListener) {
        final String url = getAbsoluteUrl(scriptUrl);
        ResourceLoadEvent event = new ResourceLoadEvent(this, url, false);
        if (loadedResources.contains(url)) {
            if (resourceLoadListener != null) {
                resourceLoadListener.onLoad(event);
            }
            return;
        }

        if (preloadListeners.containsKey(url)) {
            // Preload going on, continue when preloaded
            preloadResource(url, new ResourceLoadListener() {
                @Override
                public void onLoad(ResourceLoadEvent event) {
                    loadScript(url, resourceLoadListener);
                }

                @Override
                public void onError(ResourceLoadEvent event) {
                    // Preload failed -> signal error to own listener
                    if (resourceLoadListener != null) {
                        resourceLoadListener.onError(event);
                    }
                }
            });
            return;
        }

        if (addListener(url, resourceLoadListener, loadListeners)) {
            ScriptElement scriptTag = Document.get().createScriptElement();
            scriptTag.setSrc(url);
            scriptTag.setType("text/javascript");
            addOnloadHandler(scriptTag, new ResourceLoadListener() {
                @Override
                public void onLoad(ResourceLoadEvent event) {
                    fireLoad(event);
                }

                @Override
                public void onError(ResourceLoadEvent event) {
                    fireError(event);
                }
            }, event);
            head.appendChild(scriptTag);
        }
    }

    private static String getAbsoluteUrl(String url) {
        AnchorElement a = Document.get().createAnchorElement();
        a.setHref(url);
        return a.getHref();
    }

    /**
     * Download a resource and notify a listener when the resource is loaded
     * without attempting to interpret the resource. When a resource has been
     * preloaded, it will be present in the browser's cache (provided the HTTP
     * headers allow caching), making a subsequent load operation complete
     * without having to wait for the resource to be downloaded again.
     * 
     * Calling this method when the resource is currently loading, currently
     * preloading, already preloaded or already loaded doesn't cause the
     * resource to be preloaded again, but the listener will still be notified
     * when appropriate.
     * 
     * @param url
     *            the url of the resource to preload
     * @param resourceLoadListener
     *            the listener that will get notified when the resource is
     *            preloaded
     */
    public void preloadResource(String url,
            ResourceLoadListener resourceLoadListener) {
        url = getAbsoluteUrl(url);
        ResourceLoadEvent event = new ResourceLoadEvent(this, url, true);
        if (loadedResources.contains(url) || preloadedResources.contains(url)) {
            // Already loaded or preloaded -> just fire listener
            if (resourceLoadListener != null) {
                resourceLoadListener.onLoad(event);
            }
            return;
        }

        if (addListener(url, resourceLoadListener, preloadListeners)
                && !loadListeners.containsKey(url)) {
            // Inject loader element if this is the first time this is preloaded
            // AND the resources isn't already being loaded in the normal way

            Element element = getPreloadElement(url);
            addOnloadHandler(element, new ResourceLoadListener() {
                @Override
                public void onLoad(ResourceLoadEvent event) {
                    fireLoad(event);
                }

                @Override
                public void onError(ResourceLoadEvent event) {
                    fireError(event);
                }
            }, event);

            // TODO Remove object when loaded (without causing spinner in FF)
            Document.get().getBody().appendChild(element);
        }
    }

    private static Element getPreloadElement(String url) {
        if (BrowserInfo.get().isIE()) {
            ScriptElement element = Document.get().createScriptElement();
            element.setSrc(url);
            element.setType("text/cache");
            return element;
        } else {
            ObjectElement element = Document.get().createObjectElement();
            element.setData(url);
            element.setType("text/plain");
            element.setHeight("0px");
            element.setWidth("0px");
            return element;
        }
    }

    private native void addOnloadHandler(Element element,
            ResourceLoadListener listener, ResourceLoadEvent event)
    /*-{
        element.onload = $entry(function() {
            element.onload = null;
            element.onerror = null;
            element.onreadystatechange = null;
            listener.@com.vaadin.client.ResourceLoader.ResourceLoadListener::onLoad(Lcom/vaadin/client/ResourceLoader$ResourceLoadEvent;)(event);
        });
        element.onerror = $entry(function() {
            element.onload = null;
            element.onerror = null;
            element.onreadystatechange = null;
            listener.@com.vaadin.client.ResourceLoader.ResourceLoadListener::onError(Lcom/vaadin/client/ResourceLoader$ResourceLoadEvent;)(event);
        });
        element.onreadystatechange = function() { 
            if ("loaded" === element.readyState || "complete" === element.readyState ) {
                element.onload(arguments[0]);
            }
        };       
    }-*/;

    /**
     * Load a stylesheet and notify a listener when the stylesheet is loaded.
     * Calling this method when the stylesheet is currently loading or already
     * loaded doesn't cause the stylesheet to be loaded again, but the listener
     * will still be notified when appropriate.
     * 
     * @param stylesheetUrl
     *            the url of the stylesheet to load
     * @param resourceLoadListener
     *            the listener that will get notified when the stylesheet is
     *            loaded
     */
    public void loadStylesheet(final String stylesheetUrl,
            final ResourceLoadListener resourceLoadListener) {
        final String url = getAbsoluteUrl(stylesheetUrl);
        final ResourceLoadEvent event = new ResourceLoadEvent(this, url, false);
        if (loadedResources.contains(url)) {
            if (resourceLoadListener != null) {
                resourceLoadListener.onLoad(event);
            }
            return;
        }

        if (preloadListeners.containsKey(url)) {
            // Preload going on, continue when preloaded
            preloadResource(url, new ResourceLoadListener() {
                @Override
                public void onLoad(ResourceLoadEvent event) {
                    loadStylesheet(url, resourceLoadListener);
                }

                @Override
                public void onError(ResourceLoadEvent event) {
                    // Preload failed -> signal error to own listener
                    if (resourceLoadListener != null) {
                        resourceLoadListener.onError(event);
                    }
                }
            });
            return;
        }

        if (addListener(url, resourceLoadListener, loadListeners)) {
            LinkElement linkElement = Document.get().createLinkElement();
            linkElement.setRel("stylesheet");
            linkElement.setType("text/css");
            linkElement.setHref(url);

            if (BrowserInfo.get().isSafari()) {
                // Safari doesn't fire any events for link elements
                // See http://www.phpied.com/when-is-a-stylesheet-really-loaded/
                Scheduler.get().scheduleFixedPeriod(new RepeatingCommand() {
                    private final Duration duration = new Duration();

                    @Override
                    public boolean execute() {
                        int styleSheetLength = getStyleSheetLength(url);
                        if (getStyleSheetLength(url) > 0) {
                            fireLoad(event);
                            return false; // Stop repeating
                        } else if (styleSheetLength == 0) {
                            // "Loaded" empty sheet -> most likely 404 error
                            fireError(event);
                            return true;
                        } else if (duration.elapsedMillis() > 60 * 1000) {
                            fireError(event);
                            return false;
                        } else {
                            return true; // Continue repeating
                        }
                    }
                }, 10);
            } else {
                addOnloadHandler(linkElement, new ResourceLoadListener() {
                    @Override
                    public void onLoad(ResourceLoadEvent event) {
                        // Chrome && IE fires load for errors, must check
                        // stylesheet data
                        if (BrowserInfo.get().isChrome()
                                || BrowserInfo.get().isIE()) {
                            int styleSheetLength = getStyleSheetLength(url);
                            // Error if there's an empty stylesheet
                            if (styleSheetLength == 0) {
                                fireError(event);
                                return;
                            }
                        }
                        fireLoad(event);
                    }

                    @Override
                    public void onError(ResourceLoadEvent event) {
                        fireError(event);
                    }
                }, event);
                if (BrowserInfo.get().isOpera()) {
                    // Opera onerror never fired, assume error if no onload in x
                    // seconds
                    new Timer() {
                        @Override
                        public void run() {
                            if (!loadedResources.contains(url)) {
                                fireError(event);
                            }
                        }
                    }.schedule(5 * 1000);
                }
            }

            head.appendChild(linkElement);
        }
    }

    private static native int getStyleSheetLength(String url)
    /*-{
        for(var i = 0; i < $doc.styleSheets.length; i++) {
            if ($doc.styleSheets[i].href === url) {
                var sheet = $doc.styleSheets[i];
                try {
                    var rules = sheet.cssRules
                    if (rules === undefined) {
                        rules = sheet.rules;
                    }
                    
                    if (rules === null) {
                        // Style sheet loaded, but can't access length because of XSS -> assume there's something there
                        return 1;
                    }
                    
                    // Return length so we can distinguish 0 (probably 404 error) from normal case.
                    return rules.length;
                } catch (err) {
                    return 1;
                }
            }
        }
        // No matching stylesheet found -> not yet loaded
        return -1;
    }-*/;

    private static boolean addListener(String url,
            ResourceLoadListener listener,
            Map<String, Collection<ResourceLoadListener>> listenerMap) {
        Collection<ResourceLoadListener> listeners = listenerMap.get(url);
        if (listeners == null) {
            listeners = new HashSet<ResourceLoader.ResourceLoadListener>();
            listeners.add(listener);
            listenerMap.put(url, listeners);
            return true;
        } else {
            listeners.add(listener);
            return false;
        }
    }

    private void fireError(ResourceLoadEvent event) {
        String resource = event.getResourceUrl();

        Collection<ResourceLoadListener> listeners;
        if (event.isPreload()) {
            // Also fire error for load listeners
            fireError(new ResourceLoadEvent(this, resource, false));
            listeners = preloadListeners.remove(resource);
        } else {
            listeners = loadListeners.remove(resource);
        }
        if (listeners != null && !listeners.isEmpty()) {
            for (ResourceLoadListener listener : listeners) {
                if (listener != null) {
                    listener.onError(event);
                }
            }
        }
    }

    private void fireLoad(ResourceLoadEvent event) {
        String resource = event.getResourceUrl();
        Collection<ResourceLoadListener> listeners;
        if (event.isPreload()) {
            preloadedResources.add(resource);
            listeners = preloadListeners.remove(resource);
        } else {
            if (preloadListeners.containsKey(resource)) {
                // Also fire preload events for potential listeners
                fireLoad(new ResourceLoadEvent(this, resource, true));
            }
            preloadedResources.remove(resource);
            loadedResources.add(resource);
            listeners = loadListeners.remove(resource);
        }
        if (listeners != null && !listeners.isEmpty()) {
            for (ResourceLoadListener listener : listeners) {
                if (listener != null) {
                    listener.onLoad(event);
                }
            }
        }
    }

}
