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

/**
 * Session Storage and Restoration
 */

/* :::::::: Constants and Helpers ::::::::::::::: */

const EXPORTED_SYMBOLS = ["sessionStoreManager"];

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;

Cu.import("resource://gre/modules/AsyncShutdown.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/osfile.jsm");
Cu.import("resource://gre/modules/Services.jsm");

/**
 * asuth arbitrarily chose this value to trade-off powersaving,
 * processor usage, and recency of state in the face of the impossibility of
 * our crashing; he also worded this.
 */
const SESSION_AUTO_SAVE_DEFAULT_MS = 300000; // 5 minutes

/* :::::::: The Module ::::::::::::::: */

var sessionStoreManager =
{
  _initialized: false,

  _sessionAutoSaveTimer: null,

  _sessionAutoSaveTimerIntervalMS: SESSION_AUTO_SAVE_DEFAULT_MS,

  /**
   * The persisted state of the previous session. This is resurrected
   * from disk when the module is initialized and cleared when all
   * required windows have been restored.
   */
  _initialState: null,

  /**
   * The string containing the JSON stringified representation of the last
   * state we wrote to disk.
   */
  _currentStateString: null,

  /**
   * A flag indicating whether the state "just before shutdown" of the current
   * session has been persisted to disk. See |observe| and |unloadingWindow|
   * for justification on why we need this.
   */
  _shutdownStateSaved: false,

  /**
   * Cache the session file async writeAtomic Promise for AsyncShutdown.
   */
  _promise: null,

  /**
   * Gets the nsIFile used for session storage.
   */
  get sessionFile()
  {
    let sessionFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
    sessionFile.append("session.json");
    return sessionFile;
  },

  /**
   * This is called on startup, and when a new 3 pane window is opened after
   * the last 3 pane window was closed (e.g., on the mac, closing the last
   * window doesn't shut down the app).
   */
  _init: function ssm_init()
  {
    this._loadSessionFile();

    // we listen for "quit-application-granted" instead of
    // "quit-application-requested" because other observers of the
    // latter can cancel the shutdown.
    Services.obs.addObserver(this, "quit-application-granted", false);

    this.startPeriodicSave();

    this._initialized = true;
  },

  /**
   * Loads the session file into _initialState. This should only be called by
   * _init and a unit test.
   */
  _loadSessionFile: function ssm_loadSessionFile()
  {
    if (!this.sessionFile.exists())
      return;

    // Read the session state data from file, synchronously.
    let inStream = Cc["@mozilla.org/network/file-input-stream;1"]
                   .createInstance(Ci.nsIFileInputStream);
    inStream.init(this.sessionFile, -1, 0, 0);
    let data = NetUtil.readInputStreamToString(inStream,
                                               inStream.available(),
                                               { charset: "UTF-8" });
    inStream.close();

    // Clear the current state so that subsequent writes won't think
    // the state hasn't changed.
    this._currentStateString = null;

    try {
      // Parse the session state into a JSON object.
      this._initialState = JSON.parse(data);
    }
    catch (ex) {
      Cu.reportError("sessionStoreManager: error in session state data, " + ex);
    }

    if (!data || !this._initialState) {
      // If the file exists but there is a data read or parse fail, save the
      // bad file.
      let errorFile = "session_error_" +
                      (new Date().toISOString()).replace(/\D/g, "") + ".json";
      let errorFilePath = OS.Path.join(OS.Constants.Path.profileDir, errorFile);
      OS.File.move(this.sessionFile.path, errorFilePath)
             .then(null, error => Cu.reportError("sessionStoreManager: failed to rename " +
                                                 this.sessionFile.path + " to " +
                                                 errorFilePath + ": " + error));
    }
  },

  /**
   * Opens the windows that were open in the previous session.
   */
  _openOtherRequiredWindows: function ssm_openOtherRequiredWindows(aWindow)
  {
    // XXX we might want to display a restore page and let the user decide
    // whether to restore the other windows, just like Firefox does.

    if (!this._initialState || !this._initialState.windows || !aWindow)
      return;

    for (var i = 0; i < this._initialState.windows.length; ++i)
      aWindow.open(
             "chrome://messenger/content/messenger.xul",
             "_blank",
             "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar");
  },

  /**
   * Writes the state object to disk.
   */
  _saveStateObject: function ssm_saveStateObject(aStateObj)
  {
    let data = JSON.stringify(aStateObj);

    // Write async to disk only if state changed since last write.
    if (data == this._currentStateString)
      return;

    this._promise = OS.File
      .writeAtomic(this.sessionFile.path, data,
                   { tmpPath: this.sessionFile.path + ".tmp",
                     flush: true })
      .then(() => sessionStoreManager._currentStateString = data,
            error => Cu.reportError("sessionStoreManager: error " +
                                    "storing session state data, " +
                                    error))
  },

  /**
   * @return an empty state object that can be populated with window states.
   */
  _createStateObject: function ssm_createStateObject()
  {
    return {
      rev: 0,
      windows: []
    };
  },

  /**
   * Writes the state of all currently open 3pane windows to disk.
   */
  _saveState: function ssm_saveState()
  {
    let state = this._createStateObject();

    // XXX we'd like to support other window types in future, but for now
    // only get the 3pane windows.
    let enumerator = Services.wm.getEnumerator("mail:3pane");
    while (enumerator.hasMoreElements()) {
      let win = enumerator.getNext();
      if (win && "complete" == win.document.readyState &&
          win.getWindowStateForSessionPersistence)
        state.windows.push(win.getWindowStateForSessionPersistence());
    }

    this._saveStateObject(state);
  },

/* ........ Timer Callback ................*/

  _sessionAutoSaveTimerCallback: function ssm_sessionAutoSaveTimerCallback()
  {
    sessionStoreManager._saveState();
  },

/* ........ Observer Notification Handler ................*/

  observe: function ssm_observe(aSubject, aTopic, aData) {
    switch (aTopic) {
    // This is observed before any windows start unloading if something other
    // than the last 3pane window closing requested the application be
    // shutdown. For example, when the user quits via the file menu.
    case "quit-application-granted":
      if (!this._shutdownStateSaved) {
        this.stopPeriodicSave();
        this._saveState();

        // this is to ensure we don't clobber the saved state when the
        // 3pane windows unload.
        this._shutdownStateSaved = true;
      }
      break;
    }
  },

/* ........ Public API ................*/

  /**
   * Called by each 3pane window instance when it loads.
   *
   * @return a window state object if aWindow was opened as a result of a
   *         session restoration, null otherwise.
   */
  loadingWindow: function ssm_loadingWindow(aWindow)
  {
    let firstWindow = !this._initialized || this._shutdownStateSaved;
    if (firstWindow)
      this._init();

    // If we are seeing a new 3-pane, we are obviously not in a shutdown
    // state anymore.  (This would happen if all the 3panes got closed but
    // we did not quit because another window was open and then a 3pane showed
    // up again.  This can happen in both unit tests and real life.)
    // We treat this case like the first window case, and do a session restore.
    this._shutdownStateSaved = false;

    let windowState = null;
    if (this._initialState && this._initialState.windows) {
      windowState = this._initialState.windows.pop();
      if (0 == this._initialState.windows.length)
        this._initialState = null;
    }

    if (firstWindow)
      this._openOtherRequiredWindows(aWindow);

    return windowState;
  },

  /**
   * Called by each 3pane window instance when it unloads. If aWindow is the
   * last 3pane window, its state is persisted. The last 3pane window unloads
   * first before the "quit-application-granted" event is generated.
   */
  unloadingWindow: function ssm_unloadingWindow(aWindow)
  {
    if (!this._shutdownStateSaved) {
      // determine whether aWindow is the last open window
      let lastWindow = true;
      let enumerator = Services.wm.getEnumerator("mail:3pane");
      while (enumerator.hasMoreElements()) {
        if (enumerator.getNext() != aWindow)
          lastWindow = false;
      }

      if (lastWindow) {
        // last chance to save any state for the current session since
        // aWindow is the last 3pane window and the "quit-application-granted"
        // event is observed AFTER this.
        this.stopPeriodicSave();

        let state = this._createStateObject();
        state.windows.push(aWindow.getWindowStateForSessionPersistence());
        this._saveStateObject(state);

        // XXX this is to ensure we don't clobber the saved state when we
        // observe the "quit-application-granted" event.
        this._shutdownStateSaved = true;
      }
    }
  },

  /**
   * Stops periodic session persistence.
   */
  stopPeriodicSave: function ssm_stopPeriodicSave()
  {
    if (this._sessionAutoSaveTimer) {
      this._sessionAutoSaveTimer.cancel();

      delete this._sessionAutoSaveTimer;
      this._sessionAutoSaveTimer = null;
    }
  },

  /**
   * Starts periodic session persistence.
   */
  startPeriodicSave: function ssm_startPeriodicSave()
  {
    if (!this._sessionAutoSaveTimer) {
      this._sessionAutoSaveTimer = Cc["@mozilla.org/timer;1"]
                                   .createInstance(Ci.nsITimer);

      this._sessionAutoSaveTimer.initWithCallback(
                                   this._sessionAutoSaveTimerCallback,
                                   this._sessionAutoSaveTimerIntervalMS,
                                   Ci.nsITimer.TYPE_REPEATING_SLACK);
    }
  }
};

AsyncShutdown.profileBeforeChange.addBlocker(
  "sessionStoreManager: session.json",
  () => { return sessionStoreManager._promise; }
);
