/*
 * Copyright 2011 Google Inc.
 * 
 * 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.google.gwt.core.client;

/*
 * Design note: This class intentionally does not use the GWT DOM wrappers so
 * that this code can pull in as few dependencies as possible and live in the
 * Core module.
 */

/**
 * Dynamically create a script tag and attach it to the DOM.
 * 
 * Usage with script as local string:
 * <p>
 * 
 * <pre>
 *   String scriptBody = "var foo = ...";
 *   ScriptInjector.fromString(scriptBody).inject();
 * </pre>
 * <p>
 * Usage with script loaded as URL:
 * <p>
 * 
 * <pre>
 *   ScriptInjector.fromUrl("http://example.com/foo.js").setCallback(
 *     new Callback<Void, Exception>() {
 *        public void onFailure(Exception reason) {
 *          Window.alert("Script load failed.");
 *        }
 *        public void onSuccess(Void result) {
 *          Window.alert("Script load success.");
 *        }
 *     }).inject();
 * </pre>
 * 
 * 
 */
public class ScriptInjector {

  /**
   * Builder for directly injecting a script body into the DOM.
   */
  public static class FromString {
    private boolean removeTag = true;
    private final String scriptBody;
    private JavaScriptObject window;

    /**
     * @param scriptBody The script text to install into the document.
     */
    public FromString(String scriptBody) {
      this.scriptBody = scriptBody;
    }

    /**
     * Injects a script into the DOM. The JavaScript is evaluated and will be
     * available immediately when this call returns.
     * 
     * By default, the script is installed in the same window that the GWT code
     * is installed in.
     * 
     * @return the script element created for the injection. Note that it may be
     *         removed from the DOM.
     */
    public JavaScriptObject inject() {
      JavaScriptObject wnd = (window == null) ? nativeDefaultWindow() : window;
      assert wnd != null;
      JavaScriptObject doc = nativeGetDocument(wnd);
      assert doc != null;
      JavaScriptObject scriptElement = nativeMakeScriptElement(doc);
      assert scriptElement != null;
      nativeSetText(scriptElement, scriptBody);
      nativePropagateScriptNonceIfPossible(doc, scriptElement);
      nativeAttachToHead(doc, scriptElement);
      if (removeTag) {
        nativeRemove(scriptElement);
      }
      return scriptElement;
    }

    /**
     * @param removeTag If true, remove the tag immediately after injecting the
     *          source. This shrinks the DOM, possibly at the expense of
     *          readability if you are debugging javaScript.
     * 
     *          Default value is {@code true}.
     */
    public FromString setRemoveTag(boolean removeTag) {
      this.removeTag = removeTag;
      return this;
    }

    /**
     * @param window Specify which window to use to install the script. If not
     *          specified, the top current window GWT is loaded in is used.
     */
    public FromString setWindow(JavaScriptObject window) {
      this.window = window;
      return this;
    }
  }

  /**
   * Build an injection call for adding a script by URL.
   */
  public static class FromUrl {
    private Callback<Void, Exception> callback;
    private boolean removeTag = false;
    private final String scriptUrl;
    private JavaScriptObject window;

    private FromUrl(String scriptUrl) {
      this.scriptUrl = scriptUrl;
    }

    /**
     * Injects an external JavaScript reference into the document and optionally
     * calls a callback when it finishes loading.
     * 
     * @return the script element created for the injection.
     */
    public JavaScriptObject inject() {
      JavaScriptObject wnd = (window == null) ? nativeDefaultWindow() : window;
      assert wnd != null;
      JavaScriptObject doc = nativeGetDocument(wnd);
      assert doc != null;
      JavaScriptObject scriptElement = nativeMakeScriptElement(doc);
      assert scriptElement != null;
      if (callback != null || removeTag) {
        attachListeners(scriptElement, callback, removeTag);
      }
      nativeSetSrc(scriptElement, scriptUrl);
      nativePropagateScriptNonceIfPossible(doc, scriptElement);
      nativeAttachToHead(doc, scriptElement);
      return scriptElement;
    }

    /**
     * Specify a callback to be invoked when the script is loaded or loading
     * encounters an error.
     * <p>
     * <b>Warning:</b> This class <b>does not</b> control whether or not a URL
     * has already been injected into the document. The client of this class has
     * the responsibility of keeping score of the injected JavaScript files.
     * <p>
     * <b>Known bugs:</b>  This class uses the script tag's <code>onerror()
     * </code> callback to attempt to invoke onFailure() if the 
     * browser detects a load failure.  This is not reliable on all browsers 
     * (Doesn't work on IE or Safari 3 or less).
     * <p>
     * On Safari version 3 and prior, the onSuccess() callback may be invoked
     * even when the load of a page fails.  
     * <p>
     * To support failure notification on IE and older browsers, you should 
     * check some side effect of the script (such as a defined function)
     * to see if loading the script worked and include timeout logic.
     * 
     * @param callback callback that gets invoked asynchronously.
     */
    public FromUrl setCallback(Callback<Void, Exception> callback) {
      this.callback = callback;
      return this;
    }

    /**
     * @param removeTag If true, remove the tag after the script finishes
     *          loading. This shrinks the DOM, possibly at the expense of
     *          readability if you are debugging javaScript.
     *
     *          Default value is {@code false}, but this may change in a future
     *          release.
     */
    public FromUrl setRemoveTag(boolean removeTag) {
      this.removeTag = removeTag;
      return this;
    }

    /**
     * This call allows you to specify which DOM window object to install the
     * script tag in. To install into the Top level window call
     * 
     * <code>
     *   builder.setWindow(ScriptInjector.TOP_WINDOW);
     * </code>
     * 
     * @param window Specifies which window to install in.
     */
    public FromUrl setWindow(JavaScriptObject window) {
      this.window = window;
      return this;
    }
  }

  /**
   * Returns the top level window object. Use this to inject a script so that
   * global variable references are available under <code>$wnd</code> in JSNI
   * access.
   * <p>
   * Note that if your GWT app is loaded from a different domain than the top
   * window, you may not be able to add a script element to the top window.
   */
  public static final JavaScriptObject TOP_WINDOW = nativeTopWindow();

  /**
   * Build an injection call for directly setting the script text in the DOM.
   * 
   * @param scriptBody the script text to be injected and immediately executed.
   */
  public static FromString fromString(String scriptBody) {
    return new FromString(scriptBody);
  }

  /**
   * Build an injection call for adding a script by URL.
   * 
   * @param scriptUrl URL of the JavaScript to be injected.
   */
  public static FromUrl fromUrl(String scriptUrl) {
    return new FromUrl(scriptUrl);
  }

  /**
   * Attaches event handlers to a script DOM element that will run just once a
   * callback when it gets successfully loaded.
   * <p>
   * <b>IE Notes:</b> Internet Explorer calls {@code onreadystatechanged}
   * several times while varying the {@code readyState} property: in theory,
   * {@code "complete"} means the content is loaded, parsed and ready to be
   * used, but in practice, {@code "complete"} happens when the JS file was
   * already cached, and {@code "loaded"} happens when it was transferred over
   * the network. Other browsers just call the {@code onload} event handler. To
   * ensure the callback will be called at most once, we clear out both event
   * handlers when the callback runs for the first time. More info at the <a
   * href="http://www.phpied.com/javascript-include-ready-onload/">phpied.com
   * blog</a>.
   * <p>
   * In IE, do not trust the "order" of {@code readyState} values. For instance,
   * in IE 8 running in Vista, if the JS file is cached, only {@code "complete"}
   * will happen, but if the file has to be downloaded, {@code "loaded"} can
   * fire in parallel with {@code "loading"}.
   * 
   * 
   * @param scriptElement element to which the event handlers will be attached
   * @param callback callback that runs when the script is loaded and parsed.
   */
  private static native void attachListeners(JavaScriptObject scriptElement,
      Callback<Void, Exception> callback, boolean removeTag) /*-{
    function clearCallbacks() {
      scriptElement.onerror = scriptElement.onreadystatechange = scriptElement.onload = null;
      if (removeTag) {
        @com.google.gwt.core.client.ScriptInjector::nativeRemove(Lcom/google/gwt/core/client/JavaScriptObject;)(scriptElement);
      }
    }
    scriptElement.onload = $entry(function() {
      clearCallbacks();
      if (callback) {
        callback.@com.google.gwt.core.client.Callback::onSuccess(Ljava/lang/Object;)(null);
      }
    });
    // or possibly more portable script_tag.addEventListener('error', function(){...}, true);
    scriptElement.onerror = $entry(function() {
      clearCallbacks();
      if (callback) {
        var ex = @com.google.gwt.core.client.CodeDownloadException::new(Ljava/lang/String;)("onerror() called.");
        callback.@com.google.gwt.core.client.Callback::onFailure(Ljava/lang/Object;)(ex);
      }
    });
    scriptElement.onreadystatechange = $entry(function() {
      if (/loaded|complete/.test(scriptElement.readyState)) {
        scriptElement.onload();
      }
    });
  }-*/;

  private static native void nativeAttachToHead(JavaScriptObject doc, JavaScriptObject scriptElement) /*-{
    doc.head.appendChild(scriptElement);
  }-*/;

  private static native JavaScriptObject nativeDefaultWindow() /*-{
    return window;
  }-*/;

  private static native JavaScriptObject nativeGetDocument(JavaScriptObject wnd) /*-{
    return wnd.document;
  }-*/;

  private static native JavaScriptObject nativeMakeScriptElement(JavaScriptObject doc) /*-{
    return doc.createElement("script");
  }-*/;

  private static native void nativeRemove(JavaScriptObject scriptElement) /*-{
    scriptElement.parentNode.removeChild(scriptElement);
  }-*/;

  private static native void nativeSetSrc(JavaScriptObject element, String url) /*-{
    element.src = url;
  }-*/;

  private static native void nativeSetText(JavaScriptObject element, String scriptBody) /*-{
    element.text = scriptBody;
  }-*/;

  private static native void nativePropagateScriptNonceIfPossible(
      JavaScriptObject doc, JavaScriptObject element) /*-{
    if (doc.querySelector && doc.querySelector('script[nonce]')) {
      var firstNoncedScript = doc.querySelector('script[nonce]');
      var nonce = firstNoncedScript['nonce'] || firstNoncedScript.getAttribute('nonce');
      element.setAttribute('nonce', nonce);
    }
  }-*/;

  private static native JavaScriptObject nativeTopWindow() /*-{
    return $wnd;
  }-*/;

  /**
   * Utility class - do not instantiate
   */
  private ScriptInjector() {
  }
}
