"use strict";

/* eslint-env mozilla/frame-script */

var Ci = Components.interfaces;

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                  "resource://gre/modules/WebNavigationFrames.jsm");

function loadListener(event) {
  let document = event.target;
  let window = document.defaultView;
  let url = document.documentURI;
  let frameId = WebNavigationFrames.getFrameId(window);
  let parentFrameId = WebNavigationFrames.getParentFrameId(window);
  sendAsyncMessage("Extension:DOMContentLoaded", {frameId, parentFrameId, url});
}

addEventListener("DOMContentLoaded", loadListener);
addMessageListener("Extension:DisableWebNavigation", () => {
  removeEventListener("DOMContentLoaded", loadListener);
});

var CreatedNavigationTargetListener = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]),

  init() {
    Services.obs.addObserver(this, "webNavigation-createdNavigationTarget-from-js");
  },
  uninit() {
    Services.obs.removeObserver(this, "webNavigation-createdNavigationTarget-from-js");
  },

  observe(subject, topic, data) {
    if (!(subject instanceof Ci.nsIPropertyBag2)) {
      return;
    }

    let props = subject.QueryInterface(Ci.nsIPropertyBag2);

    const createdDocShell = props.getPropertyAsInterface("createdTabDocShell", Ci.nsIDocShell);
    const sourceDocShell = props.getPropertyAsInterface("sourceTabDocShell", Ci.nsIDocShell);

    const isSourceTabDescendant = sourceDocShell.sameTypeRootTreeItem === docShell;

    if (docShell !== createdDocShell && docShell !== sourceDocShell &&
        !isSourceTabDescendant) {
      // if the createdNavigationTarget is not related to this docShell
      // (this docShell is not the newly created docShell, it is not the source docShell,
      // and the source docShell is not a descendant of it)
      // there is nothing to do here and return early.
      return;
    }

    const isSourceTab = docShell === sourceDocShell || isSourceTabDescendant;
    const sourceFrameId = WebNavigationFrames.getDocShellFrameId(sourceDocShell);
    const createdWindowId = WebNavigationFrames.getDocShellFrameId(createdDocShell);

    let url;
    if (props.hasKey("url")) {
      url = props.getPropertyAsACString("url");
    }

    sendAsyncMessage("Extension:CreatedNavigationTarget", {
      url,
      sourceFrameId,
      createdWindowId,
      isSourceTab,
    });
  },
};

var FormSubmitListener = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                         Ci.nsIFormSubmitObserver,
                                         Ci.nsISupportsWeakReference]),
  init() {
    this.formSubmitWindows = new WeakSet();
    Services.obs.addObserver(FormSubmitListener, "earlyformsubmit");
  },

  uninit() {
    Services.obs.removeObserver(FormSubmitListener, "earlyformsubmit");
    this.formSubmitWindows = new WeakSet();
  },

  notify: function(form, window, actionURI) {
    try {
      this.formSubmitWindows.add(window);
    } catch (e) {
      Cu.reportError("Error in FormSubmitListener.notify");
    }
  },

  hasAndForget: function(window) {
    let has = this.formSubmitWindows.has(window);
    this.formSubmitWindows.delete(window);
    return has;
  },
};

var WebProgressListener = {
  init: function() {
    // This WeakMap (DOMWindow -> nsIURI) keeps track of the pathname and hash
    // of the previous location for all the existent docShells.
    this.previousURIMap = new WeakMap();

    // Populate the above previousURIMap by iterating over the docShells tree.
    for (let currentDocShell of WebNavigationFrames.iterateDocShellTree(docShell)) {
      let win = currentDocShell.QueryInterface(Ci.nsIInterfaceRequestor)
                               .getInterface(Ci.nsIDOMWindow);
      let {currentURI} = currentDocShell.QueryInterface(Ci.nsIWebNavigation);

      this.previousURIMap.set(win, currentURI);
    }

    // This WeakSet of DOMWindows keeps track of the attempted refresh.
    this.refreshAttemptedDOMWindows = new WeakSet();

    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIWebProgress);
    webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
                                          Ci.nsIWebProgress.NOTIFY_REFRESH |
                                          Ci.nsIWebProgress.NOTIFY_LOCATION);
  },

  uninit() {
    if (!docShell) {
      return;
    }
    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIWebProgress);
    webProgress.removeProgressListener(this);
  },

  onRefreshAttempted: function onRefreshAttempted(webProgress, URI, delay, sameURI) {
    this.refreshAttemptedDOMWindows.add(webProgress.DOMWindow);

    // If this function doesn't return true, the attempted refresh will be blocked.
    return true;
  },

  onStateChange: function onStateChange(webProgress, request, stateFlags, status) {
    let {originalURI, URI: locationURI} = request.QueryInterface(Ci.nsIChannel);

    // Prevents "about", "chrome", "resource" and "moz-extension" URI schemes to be
    // reported with the resolved "file" or "jar" URIs. (see Bug 1246125 for rationale)
    if (locationURI.schemeIs("file") || locationURI.schemeIs("jar")) {
      let shouldUseOriginalURI = originalURI.schemeIs("about") ||
                                 originalURI.schemeIs("chrome") ||
                                 originalURI.schemeIs("resource") ||
                                 originalURI.schemeIs("moz-extension");

      locationURI = shouldUseOriginalURI ? originalURI : locationURI;
    }

    this.sendStateChange({webProgress, locationURI, stateFlags, status});

    // Based on the docs of the webNavigation.onCommitted event, it should be raised when:
    // "The document  might still be downloading, but at least part of
    // the document has been received"
    // and for some reason we don't fire onLocationChange for the
    // initial navigation of a sub-frame.
    // For the above two reasons, when the navigation event is related to
    // a sub-frame we process the document change here and
    // then send an "Extension:DocumentChange" message to the main process,
    // where it will be turned into a webNavigation.onCommitted event.
    // (see Bug 1264936 and Bug 125662 for rationale)
    if ((webProgress.DOMWindow.top != webProgress.DOMWindow) &&
        (stateFlags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT)) {
      this.sendDocumentChange({webProgress, locationURI, request});
    }
  },

  onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) {
    let {DOMWindow} = webProgress;

    // Get the previous URI loaded in the DOMWindow.
    let previousURI = this.previousURIMap.get(DOMWindow);

    // Update the URI in the map with the new locationURI.
    this.previousURIMap.set(DOMWindow, locationURI);

    let isSameDocument = (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT);

    // When a frame navigation doesn't change the current loaded document
    // (which can be due to history.pushState/replaceState or to a changed hash in the url),
    // it is reported only to the onLocationChange, for this reason
    // we process the history change here and then we are going to send
    // an "Extension:HistoryChange" to the main process, where it will be turned
    // into a webNavigation.onHistoryStateUpdated/onReferenceFragmentUpdated event.
    if (isSameDocument) {
      this.sendHistoryChange({webProgress, previousURI, locationURI, request});
    } else if (webProgress.DOMWindow.top == webProgress.DOMWindow) {
      // We have to catch the document changes from top level frames here,
      // where we can detect the "server redirect" transition.
      // (see Bug 1264936 and Bug 125662 for rationale)
      this.sendDocumentChange({webProgress, locationURI, request});
    }
  },

  sendStateChange({webProgress, locationURI, stateFlags, status}) {
    let data = {
      requestURL: locationURI.spec,
      frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
      parentFrameId: WebNavigationFrames.getParentFrameId(webProgress.DOMWindow),
      status,
      stateFlags,
    };

    sendAsyncMessage("Extension:StateChange", data);
  },

  sendDocumentChange({webProgress, locationURI, request}) {
    let {loadType, DOMWindow} = webProgress;
    let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow});

    let data = {
      frameTransitionData,
      location: locationURI ? locationURI.spec : "",
      frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
      parentFrameId: WebNavigationFrames.getParentFrameId(webProgress.DOMWindow),
    };

    sendAsyncMessage("Extension:DocumentChange", data);
  },

  sendHistoryChange({webProgress, previousURI, locationURI, request}) {
    let {loadType, DOMWindow} = webProgress;

    let isHistoryStateUpdated = false;
    let isReferenceFragmentUpdated = false;

    let pathChanged = !(previousURI && locationURI.equalsExceptRef(previousURI));
    let hashChanged = !(previousURI && previousURI.ref == locationURI.ref);

    // When the location changes but the document is the same:
    // - path not changed and hash changed -> |onReferenceFragmentUpdated|
    //   (even if it changed using |history.pushState|)
    // - path not changed and hash not changed -> |onHistoryStateUpdated|
    //   (only if it changes using |history.pushState|)
    // - path changed -> |onHistoryStateUpdated|

    if (!pathChanged && hashChanged) {
      isReferenceFragmentUpdated = true;
    } else if (loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) {
      isHistoryStateUpdated = true;
    } else if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) {
      isHistoryStateUpdated = true;
    }

    if (isHistoryStateUpdated || isReferenceFragmentUpdated) {
      let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow});

      let data = {
        frameTransitionData,
        isHistoryStateUpdated, isReferenceFragmentUpdated,
        location: locationURI ? locationURI.spec : "",
        frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
        parentFrameId: WebNavigationFrames.getParentFrameId(webProgress.DOMWindow),
      };

      sendAsyncMessage("Extension:HistoryChange", data);
    }
  },

  getFrameTransitionData({loadType, request, DOMWindow}) {
    let frameTransitionData = {};

    if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) {
      frameTransitionData.forward_back = true;
    }

    if (loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) {
      frameTransitionData.reload = true;
    }

    if (request instanceof Ci.nsIChannel) {
      if (request.loadInfo.redirectChain.length) {
        frameTransitionData.server_redirect = true;
      }
    }

    if (FormSubmitListener.hasAndForget(DOMWindow)) {
      frameTransitionData.form_submit = true;
    }

    if (this.refreshAttemptedDOMWindows.has(DOMWindow)) {
      this.refreshAttemptedDOMWindows.delete(DOMWindow);
      frameTransitionData.client_redirect = true;
    }

    return frameTransitionData;
  },

  QueryInterface: XPCOMUtils.generateQI([
    Ci.nsIWebProgressListener,
    Ci.nsIWebProgressListener2,
    Ci.nsISupportsWeakReference,
  ]),
};

var disabled = false;
WebProgressListener.init();
FormSubmitListener.init();
CreatedNavigationTargetListener.init();
addEventListener("unload", () => {
  if (!disabled) {
    disabled = true;
    WebProgressListener.uninit();
    FormSubmitListener.uninit();
    CreatedNavigationTargetListener.uninit();
  }
});
addMessageListener("Extension:DisableWebNavigation", () => {
  if (!disabled) {
    disabled = true;
    WebProgressListener.uninit();
    FormSubmitListener.uninit();
    CreatedNavigationTargetListener.uninit();
  }
});
