/* 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/. */

/**
 * This file has two actors, RefreshBlockerChild js a window actor which
 * handles the refresh notifications. RefreshBlockerObserverChild is a process
 * actor that enables refresh blocking on each docshell that is created.
 */

import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";

const REFRESHBLOCKING_PREF = "accessibility.blockautorefresh";

var progressListener = {
  // Bug 1247100 - When a refresh is caused by an HTTP header,
  // onRefreshAttempted will be fired before onLocationChange.
  // When a refresh is caused by a <meta> tag in the document,
  // onRefreshAttempted will be fired after onLocationChange.
  //
  // We only ever want to send a message to the parent after
  // onLocationChange has fired, since the parent uses the
  // onLocationChange update to clear transient notifications.
  // Sending the message before onLocationChange will result in
  // us creating the notification, and then clearing it very
  // soon after.
  //
  // To account for both cases (onRefreshAttempted before
  // onLocationChange, and onRefreshAttempted after onLocationChange),
  // we'll hold a mapping of DOM Windows that we see get
  // sent through both onLocationChange and onRefreshAttempted.
  // When either run, they'll check the WeakMap for the existence
  // of the DOM Window. If it doesn't exist, it'll add it. If
  // it finds it, it'll know that it's safe to send the message
  // to the parent, since we know that both have fired.
  //
  // The DOM Window is removed from blockedWindows when we notice
  // the nsIWebProgress change state to STATE_STOP for the
  // STATE_IS_WINDOW case.
  //
  // DOM Windows are mapped to a JS object that contains the data
  // to be sent to the parent to show the notification. Since that
  // data is only known when onRefreshAttempted is fired, it's only
  // ever stashed in the map if onRefreshAttempted fires first -
  // otherwise, null is set as the value of the mapping.
  blockedWindows: new WeakMap(),

  /**
   * Notices when the nsIWebProgress transitions to STATE_STOP for
   * the STATE_IS_WINDOW case, which will clear any mappings from
   * blockedWindows.
   */
  onStateChange(aWebProgress, aRequest, aStateFlags) {
    if (
      aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW &&
      aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
    ) {
      this.blockedWindows.delete(aWebProgress.DOMWindow);
    }
  },

  /**
   * Notices when the location has changed. If, when running,
   * onRefreshAttempted has already fired for this DOM Window, will
   * send the appropriate refresh blocked data to the parent.
   */
  onLocationChange(aWebProgress) {
    let win = aWebProgress.DOMWindow;
    if (this.blockedWindows.has(win)) {
      let data = this.blockedWindows.get(win);
      if (data) {
        // We saw onRefreshAttempted before onLocationChange, so
        // send the message to the parent to show the notification.
        this.send(win, data);
      }
    } else {
      this.blockedWindows.set(win, null);
    }
  },

  /**
   * Notices when a refresh / reload was attempted. If, when running,
   * onLocationChange has not yet run, will stash the appropriate data
   * into the blockedWindows map to be sent when onLocationChange fires.
   */
  onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
    let win = aWebProgress.DOMWindow;

    let data = {
      browsingContext: win.browsingContext,
      URI: aURI.spec,
      delay: aDelay,
      sameURI: aSameURI,
    };

    if (this.blockedWindows.has(win)) {
      // onLocationChange must have fired before, so we can tell the
      // parent to show the notification.
      this.send(win, data);
    } else {
      // onLocationChange hasn't fired yet, so stash the data in the
      // map so that onLocationChange can send it when it fires.
      this.blockedWindows.set(win, data);
    }

    return false;
  },

  send(win, data) {
    // Due to the |nsDocLoader| calling its |nsIWebProgressListener|s in
    // reverse order, this will occur *before* the |BrowserChild| can send its
    // |OnLocationChange| event to the parent, but we need this message to
    // arrive after to ensure that the refresh blocker notification is not
    // immediately cleared by the |OnLocationChange| from |BrowserChild|.
    setTimeout(() => {
      // An exception can occur if refresh blocking was turned off
      // during a pageload.
      try {
        let actor = win.windowGlobalChild.getActor("RefreshBlocker");
        if (actor) {
          actor.sendAsyncMessage("RefreshBlocker:Blocked", data);
        }
      } catch (ex) {}
    }, 0);
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener2",
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),
};

export class RefreshBlockerChild extends JSWindowActorChild {
  didDestroy() {
    // If the refresh blocking preference is turned off, all of the
    // RefreshBlockerChild actors will get destroyed, so disable
    // refresh blocking only in this case.
    if (!Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
      this.disable(this.docShell);
    }
  }

  enable() {
    ChromeUtils.domProcessChild
      .getActor("RefreshBlockerObserver")
      .enable(this.docShell);
  }

  disable() {
    ChromeUtils.domProcessChild
      .getActor("RefreshBlockerObserver")
      .disable(this.docShell);
  }

  receiveMessage(message) {
    let data = message.data;

    switch (message.name) {
      case "RefreshBlocker:Refresh": {
        let docShell = data.browsingContext.docShell;
        let refreshURI = docShell.QueryInterface(Ci.nsIRefreshURI);
        let URI = Services.io.newURI(data.URI);
        refreshURI.forceRefreshURI(URI, null, data.delay);
        break;
      }

      case "PreferenceChanged":
        if (data.isEnabled) {
          this.enable(this.docShell);
        } else {
          this.disable(this.docShell);
        }
    }
  }
}

export class RefreshBlockerObserverChild extends JSProcessActorChild {
  constructor() {
    super();
    this.filtersMap = new Map();
  }

  observe(subject, topic) {
    switch (topic) {
      case "webnavigation-create":
      case "chrome-webnavigation-create":
        if (Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
          this.enable(subject.QueryInterface(Ci.nsIDocShell));
        }
        break;

      case "webnavigation-destroy":
      case "chrome-webnavigation-destroy":
        if (Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
          this.disable(subject.QueryInterface(Ci.nsIDocShell));
        }
        break;
    }
  }

  enable(docShell) {
    if (this.filtersMap.has(docShell)) {
      return;
    }

    let filter = Cc[
      "@mozilla.org/appshell/component/browser-status-filter;1"
    ].createInstance(Ci.nsIWebProgress);

    filter.addProgressListener(progressListener, Ci.nsIWebProgress.NOTIFY_ALL);

    this.filtersMap.set(docShell, filter);

    let webProgress = docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebProgress);
    webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL);
  }

  disable(docShell) {
    let filter = this.filtersMap.get(docShell);
    if (!filter) {
      return;
    }

    let webProgress = docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebProgress);
    webProgress.removeProgressListener(filter);

    filter.removeProgressListener(progressListener);
    this.filtersMap.delete(docShell);
  }
}
