/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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/. */

"use strict";

const { GeckoViewChildModule } = ChromeUtils.importESModule(
  "resource://gre/modules/GeckoViewChildModule.sys.mjs"
);
const { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs",
});

const NO_INDEX = Number.MAX_SAFE_INTEGER;
const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1;
const DEFAULT_INTERVAL_MS = 1500;

// This pref controls whether or not we send updates to the parent on a timeout
// or not, and should only be used for tests or debugging.
const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates";

const PREF_INTERVAL = "browser.sessionstore.interval";

class Handler {
  constructor(store) {
    this.store = store;
  }

  get mm() {
    return this.store.mm;
  }

  get eventDispatcher() {
    return this.store.eventDispatcher;
  }

  get messageQueue() {
    return this.store.messageQueue;
  }

  get stateChangeNotifier() {
    return this.store.stateChangeNotifier;
  }
}

/**
 * Listens for state change notifcations from webProgress and notifies each
 * registered observer for either the start of a page load, or its completion.
 */
class StateChangeNotifier extends Handler {
  constructor(store) {
    super(store);

    this._observers = new Set();
    const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor);
    const webProgress = ifreq.getInterface(Ci.nsIWebProgress);
    webProgress.addProgressListener(
      this,
      Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
    );
  }

  /**
   * Adds a given observer |obs| to the set of observers that will be notified
   * when when a new document starts or finishes loading.
   *
   * @param obs (object)
   */
  addObserver(obs) {
    this._observers.add(obs);
  }

  /**
   * Notifies all observers that implement the given |method|.
   *
   * @param method (string)
   */
  notifyObservers(method) {
    for (const obs of this._observers) {
      if (typeof obs[method] == "function") {
        obs[method]();
      }
    }
  }

  /**
   * @see nsIWebProgressListener.onStateChange
   */
  onStateChange(webProgress, request, stateFlags) {
    // Ignore state changes for subframes because we're only interested in the
    // top-document starting or stopping its load.
    if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) {
      return;
    }

    // onStateChange will be fired when loading the initial about:blank URI for
    // a browser, which we don't actually care about. This is particularly for
    // the case of unrestored background tabs, where the content has not yet
    // been restored: we don't want to accidentally send any updates to the
    // parent when the about:blank placeholder page has loaded.
    if (!this.mm.docShell.hasLoadedNonBlankURI) {
      return;
    }

    if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
      this.notifyObservers("onPageLoadStarted");
    } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
      this.notifyObservers("onPageLoadCompleted");
    }
  }
}
StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([
  "nsIWebProgressListener",
  "nsISupportsWeakReference",
]);

/**
 * Listens for changes to the session history. Whenever the user navigates
 * we will collect URLs and everything belonging to session history.
 *
 * Causes a SessionStore:update message to be sent that contains the current
 * session history.
 *
 * Example:
 *   {entries: [{url: "about:mozilla", ...}, ...], index: 1}
 */
class SessionHistoryListener extends Handler {
  constructor(store) {
    super(store);

    this._fromIdx = NO_INDEX;

    // The state change observer is needed to handle initial subframe loads.
    // It will redundantly invalidate with the SHistoryListener in some cases
    // but these invalidations are very cheap.
    this.stateChangeNotifier.addObserver(this);

    // By adding the SHistoryListener immediately, we will unfortunately be
    // notified of every history entry as the tab is restored. We don't bother
    // waiting to add the listener later because these notifications are cheap.
    // We will likely only collect once since we are batching collection on
    // a delay.
    this.mm.docShell
      .QueryInterface(Ci.nsIWebNavigation)
      .sessionHistory.legacySHistory.addSHistoryListener(this);

    // Listen for page title changes.
    this.mm.addEventListener("DOMTitleChanged", this);
  }

  uninit() {
    const sessionHistory = this.mm.docShell.QueryInterface(
      Ci.nsIWebNavigation
    ).sessionHistory;
    if (sessionHistory) {
      sessionHistory.legacySHistory.removeSHistoryListener(this);
    }
  }

  collect() {
    // We want to send down a historychange even for full collects in case our
    // session history is a partial session history, in which case we don't have
    // enough information for a full update. collectFrom(-1) tells the collect
    // function to collect all data avaliable in this process.
    if (this.mm.docShell) {
      this.collectFrom(-1);
    }
  }

  // History can grow relatively big with the nested elements, so if we don't have to, we
  // don't want to send the entire history all the time. For a simple optimization
  // we keep track of the smallest index from after any change has occured and we just send
  // the elements from that index. If something more complicated happens we just clear it
  // and send the entire history. We always send the additional info like the current selected
  // index (so for going back and forth between history entries we set the index to LAST_INDEX
  // if nothing else changed send an empty array and the additonal info like the selected index)
  collectFrom(idx) {
    if (this._fromIdx <= idx) {
      // If we already know that we need to update history fromn index N we can ignore any changes
      // tha happened with an element with index larger than N.
      // Note: initially we use NO_INDEX which is MAX_SAFE_INTEGER which means we don't ignore anything
      // here, and in case of navigation in the history back and forth we use LAST_INDEX which ignores
      // only the subsequent navigations, but not any new elements added.
      return;
    }

    this._fromIdx = idx;
    this.messageQueue.push("historychange", () => {
      if (this._fromIdx === NO_INDEX) {
        return null;
      }

      const history = SessionHistory.collect(this.mm.docShell, this._fromIdx);
      this._fromIdx = NO_INDEX;
      return history;
    });
  }

  handleEvent() {
    this.collect();
  }

  onPageLoadCompleted() {
    this.collect();
  }

  onPageLoadStarted() {
    this.collect();
  }

  OnHistoryNewEntry() {
    // We ought to collect the previously current entry as well, see bug 1350567.
    // TODO: Reenable partial history collection for performance
    // this.collectFrom(oldIndex);
    this.collect();
  }

  OnHistoryGotoIndex() {
    // We ought to collect the previously current entry as well, see bug 1350567.
    // TODO: Reenable partial history collection for performance
    // this.collectFrom(LAST_INDEX);
    this.collect();
  }

  OnHistoryPurge() {
    this.collect();
  }

  OnHistoryReload() {
    this.collect();
    return true;
  }

  OnHistoryReplaceEntry() {
    this.collect();
  }
}
SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
  "nsISHistoryListener",
  "nsISupportsWeakReference",
]);

/**
 * Listens for scroll position changes. Whenever the user scrolls the top-most
 * frame we update the scroll position and will restore it when requested.
 *
 * Causes a SessionStore:update message to be sent that contains the current
 * scroll positions as a tree of strings. If no frame of the whole frame tree
 * is scrolled this will return null so that we don't tack a property onto
 * the tabData object in the parent process.
 *
 * Example:
 *   {scroll: "100,100", zoom: {resolution: "1.5", displaySize:
 *   {height: "1600", width: "1000"}}, children:
 *   [null, null, {scroll: "200,200"}]}
 */
class ScrollPositionListener extends Handler {
  constructor(store) {
    super(store);

    SessionStoreUtils.addDynamicFrameFilteredListener(
      this.mm,
      "mozvisualscroll",
      this,
      /* capture */ false,
      /* system group */ true
    );

    SessionStoreUtils.addDynamicFrameFilteredListener(
      this.mm,
      "mozvisualresize",
      this,
      /* capture */ false,
      /* system group */ true
    );

    this.stateChangeNotifier.addObserver(this);
  }

  handleEvent() {
    this.messageQueue.push("scroll", () => this.collect());
  }

  onPageLoadCompleted() {
    this.messageQueue.push("scroll", () => this.collect());
  }

  onPageLoadStarted() {
    this.messageQueue.push("scroll", () => null);
  }

  collect() {
    // TODO: Keep an eye on bug 1525259; we may not have to manually store zoom
    // Save the current document resolution.
    let zoom = 1;
    const scrolldata =
      SessionStoreUtils.collectScrollPosition(this.mm.content) || {};
    const domWindowUtils = this.mm.content.windowUtils;
    zoom = domWindowUtils.getResolution();
    scrolldata.zoom = {};
    scrolldata.zoom.resolution = zoom;

    // Save some data that'll help in adjusting the zoom level
    // when restoring in a different screen orientation.
    const displaySize = {};
    const width = {},
      height = {};
    domWindowUtils.getDocumentViewerSize(width, height);

    displaySize.width = width.value;
    displaySize.height = height.value;

    scrolldata.zoom.displaySize = displaySize;

    return scrolldata;
  }
}

/**
 * Listens for changes to input elements. Whenever the value of an input
 * element changes we will re-collect data for the current frame tree and send
 * a message to the parent process.
 *
 * Causes a SessionStore:update message to be sent that contains the form data
 * for all reachable frames.
 *
 * Example:
 *   {
 *     formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}},
 *     children: [
 *       null,
 *       {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}}
 *     ]
 *   }
 */
class FormDataListener extends Handler {
  constructor(store) {
    super(store);

    SessionStoreUtils.addDynamicFrameFilteredListener(
      this.mm,
      "input",
      this,
      true
    );
    this.stateChangeNotifier.addObserver(this);
  }

  handleEvent() {
    this.messageQueue.push("formdata", () => this.collect());
  }

  onPageLoadStarted() {
    this.messageQueue.push("formdata", () => null);
  }

  collect() {
    return SessionStoreUtils.collectFormData(this.mm.content);
  }
}

/**
 * A message queue that takes collected data and will take care of sending it
 * to the chrome process. It allows flushing using synchronous messages and
 * takes care of any race conditions that might occur because of that. Changes
 * will be batched if they're pushed in quick succession to avoid a message
 * flood.
 */
class MessageQueue extends Handler {
  constructor(store) {
    super(store);

    /**
     * A map (string -> lazy fn) holding lazy closures of all queued data
     * collection routines. These functions will return data collected from the
     * docShell.
     */
    this._data = new Map();

    /**
     * The delay (in ms) used to delay sending changes after data has been
     * invalidated.
     */
    this.BATCH_DELAY_MS = 1000;

    /**
     * The minimum idle period (in ms) we need for sending data to chrome process.
     */
    this.NEEDED_IDLE_PERIOD_MS = 5;

    /**
     * Timeout for waiting an idle period to send data. We will set this from
     * the pref "browser.sessionstore.interval".
     */
    this._timeoutWaitIdlePeriodMs = null;

    /**
     * The current timeout ID, null if there is no queue data. We use timeouts
     * to damp a flood of data changes and send lots of changes as one batch.
     */
    this._timeout = null;

    /**
     * Whether or not sending batched messages on a timer is disabled. This should
     * only be used for debugging or testing. If you need to access this value,
     * you should probably use the timeoutDisabled getter.
     */
    this._timeoutDisabled = false;

    /**
     * True if there is already a send pending idle dispatch, set to prevent
     * scheduling more than one. If false there may or may not be one scheduled.
     */
    this._idleScheduled = false;

    this.timeoutDisabled = Services.prefs.getBoolPref(
      TIMEOUT_DISABLED_PREF,
      false
    );
    this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
      PREF_INTERVAL,
      DEFAULT_INTERVAL_MS
    );

    Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
    Services.prefs.addObserver(PREF_INTERVAL, this);
  }

  /**
   * True if batched messages are not being fired on a timer. This should only
   * ever be true when debugging or during tests.
   */
  get timeoutDisabled() {
    return this._timeoutDisabled;
  }

  /**
   * Disables sending batched messages on a timer. Also cancels any pending
   * timers.
   */
  set timeoutDisabled(val) {
    this._timeoutDisabled = val;

    if (val && this._timeout) {
      clearTimeout(this._timeout);
      this._timeout = null;
    }
  }

  uninit() {
    this.cleanupTimers();
  }

  /**
   * Cleanup pending idle callback and timer.
   */
  cleanupTimers() {
    this._idleScheduled = false;
    if (this._timeout) {
      clearTimeout(this._timeout);
      this._timeout = null;
    }
  }

  observe(subject, topic, data) {
    if (topic == "nsPref:changed") {
      switch (data) {
        case TIMEOUT_DISABLED_PREF:
          this.timeoutDisabled = Services.prefs.getBoolPref(
            TIMEOUT_DISABLED_PREF,
            false
          );
          break;
        case PREF_INTERVAL:
          this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
            PREF_INTERVAL,
            DEFAULT_INTERVAL_MS
          );
          break;
        default:
          debug`Received unknown message: ${data}`;
          break;
      }
    }
  }

  /**
   * Pushes a given |value| onto the queue. The given |key| represents the type
   * of data that is stored and can override data that has been queued before
   * but has not been sent to the parent process, yet.
   *
   * @param key (string)
   *        A unique identifier specific to the type of data this is passed.
   * @param fn (function)
   *        A function that returns the value that will be sent to the parent
   *        process.
   */
  push(key, fn) {
    this._data.set(key, fn);

    if (!this._timeout && !this._timeoutDisabled) {
      // Wait a little before sending the message to batch multiple changes.
      this._timeout = setTimeoutWithTarget(
        () => this.sendWhenIdle(),
        this.BATCH_DELAY_MS,
        this.mm.tabEventTarget
      );
    }
  }

  /**
   * Sends queued data when the remaining idle time is enough or waiting too
   * long; otherwise, request an idle time again. If the |deadline| is not
   * given, this function is going to schedule the first request.
   *
   * @param deadline (object)
   *        An IdleDeadline object passed by idleDispatch().
   */
  sendWhenIdle(deadline) {
    if (!this.mm.content) {
      // The frameloader is being torn down. Nothing more to do.
      return;
    }

    if (deadline) {
      if (
        deadline.didTimeout ||
        deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
      ) {
        this.send();
        return;
      }
    } else if (this._idleScheduled) {
      // Bail out if there's a pending run.
      return;
    }
    ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
      timeout: this._timeoutWaitIdlePeriodMs,
    });
    this._idleScheduled = true;
  }

  /**
   * Sends queued data to the chrome process.
   *
   * @param options (object)
   *        {isFinal: true} to signal this is the final message sent on unload
   */
  send(options = {}) {
    // Looks like we have been called off a timeout after the tab has been
    // closed. The docShell is gone now and we can just return here as there
    // is nothing to do.
    if (!this.mm.docShell) {
      return;
    }

    this.cleanupTimers();

    const data = {};
    for (const [key, func] of this._data) {
      const value = func();

      if (value || (key != "storagechange" && key != "historychange")) {
        data[key] = value;
      }
    }

    this._data.clear();

    try {
      // Send all data to the parent process.
      this.eventDispatcher.sendRequest({
        type: "GeckoView:StateUpdated",
        data,
        isFinal: options.isFinal || false,
        epoch: this.store.epoch,
      });
    } catch (ex) {
      if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
        warn`Failed to save session state`;
      }
    }
  }
}

class SessionStateAggregator extends GeckoViewChildModule {
  constructor(aModuleName, aMessageManager) {
    super(aModuleName, aMessageManager);

    this.mm = aMessageManager;
    this.messageQueue = new MessageQueue(this);
    this.stateChangeNotifier = new StateChangeNotifier(this);

    this.handlers = [
      new SessionHistoryListener(this),
      this.stateChangeNotifier,
      this.messageQueue,
    ];

    if (!Services.appinfo.sessionStorePlatformCollection) {
      this.handlers.push(
        new FormDataListener(this),
        new ScrollPositionListener(this)
      );
    }

    this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
  }

  receiveMessage(aMsg) {
    debug`receiveMessage: ${aMsg.name}`;

    switch (aMsg.name) {
      case "GeckoView:FlushSessionState":
        this.flush();
        break;
    }
  }

  flush() {
    // Flush the message queue, send the latest updates.
    this.messageQueue.send();
  }

  onUnload() {
    // Upon frameLoader destruction, send a final update message to
    // the parent and flush all data currently held in the child.
    this.messageQueue.send({ isFinal: true });

    for (const handler of this.handlers) {
      if (handler.uninit) {
        handler.uninit();
      }
    }

    // We don't need to take care of any StateChangeNotifier observers as they
    // will die with the content script.
  }
}

// TODO: Bug 1648158 Move SessionAggregator to the parent process
class DummySessionStateAggregator extends GeckoViewChildModule {
  constructor(aModuleName, aMessageManager) {
    super(aModuleName, aMessageManager);
    this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
  }

  receiveMessage(aMsg) {
    debug`receiveMessage: ${aMsg.name}`;

    switch (aMsg.name) {
      case "GeckoView:FlushSessionState":
        // Do nothing
        break;
    }
  }
}

const { debug, warn } = SessionStateAggregator.initLogging(
  "SessionStateAggregator"
);

const module = Services.appinfo.sessionHistoryInParent
  ? // If history is handled in the parent we don't need a session aggregator
    // TODO: Bug 1648158 remove this and do everything in the parent
    DummySessionStateAggregator.create(this)
  : SessionStateAggregator.create(this);
