/* 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 EXPORTED_SYMBOLS = ["AitcManager"];

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Webapps.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FileUtils.jsm");

Cu.import("resource://services-aitc/client.js");
Cu.import("resource://services-aitc/browserid.js");
Cu.import("resource://services-aitc/storage.js");
Cu.import("resource://services-common/log4moz.js");
Cu.import("resource://services-common/preferences.js");
Cu.import("resource://services-common/tokenserverclient.js");
Cu.import("resource://services-common/utils.js");

const PREFS = new Preferences("services.aitc.");
const INITIAL_TOKEN_DURATION = 240000; // 4 minutes
const DASHBOARD_URL = PREFS.get("dashboard.url");
const MARKETPLACE_URL = PREFS.get("marketplace.url");

/**
 * The constructor for the manager takes a callback, which will be invoked when
 * the manager is ready (construction is asynchronous). *DO NOT* call any
 * methods on this object until the callback has been invoked, doing so will
 * lead to undefined behaviour. The premadeClient and premadeToken are used
 * to bypass BrowserID for xpcshell tests, since the window object in not
 * available.
 */
function AitcManager(cb, premadeClient, premadeToken) {
  this._client = null;
  this._getTimer = null;
  this._putTimer = null;

  this._lastTokenTime = 0;
  this._tokenDuration = INITIAL_TOKEN_DURATION;
  this._premadeToken = premadeToken || null;
  this._invalidTokenFlag = false;

  this._lastEmail = null;
  this._dashboardWindow = null;

  this._log = Log4Moz.repository.getLogger("Service.AITC.Manager");
  this._log.level = Log4Moz.Level[Preferences.get("manager.log.level")];
  this._log.info("Loading AitC manager module");

  // Check if we have pending PUTs from last time.
  let self = this;
  this._pending = new AitcQueue("webapps-pending.json", function _queueDone() {
    // Inform the AitC service that we're good to go!
    self._log.info("AitC manager has finished loading");
    try {
      cb(true);
    } catch (e) {
      self._log.error(new Error("AitC manager callback threw " + e));
    }

    // Used for testing.
    if (premadeClient) {
      self._client = premadeClient;
      cb(null, true);
      return;
    }

    // Caller will invoke initialSchedule which will process any items in the
    // queue, if present.
  });
}
AitcManager.prototype = {
  /**
   * State of the user. ACTIVE implies user is looking at the dashboard,
   * PASSIVE means either not at the dashboard or the idle timer started.
   */
  _ACTIVE: 1,
  _PASSIVE: 2,

  /**
   * Smart setter that will only call _setPoll is the value changes.
   */
  _clientState: null,
  get _state() {
    return this._clientState;
  },
  set _state(value) {
    if (this._clientState == value) {
      return;
    }
    this._clientState = value;
    this._setPoll();
  },

  /**
   * Local app was just installed or uninstalled, ask client to PUT if user
   * is logged in.
   */
  appEvent: function appEvent(type, app) {
    // Add this to the equeue.
    let self = this;
    let obj = {type: type, app: app, retries: 0, lastTime: 0};
    this._pending.enqueue(obj, function _enqueued(err, rec) {
      if (err) {
        self._log.error("Could not add " + type + " " + app + " to queue");
        return;
      }

      // If we already have a client (i.e. user is logged in), attempt to PUT.
      if (self._client) {
        self._processQueue();
        return;
      }

      // If not, try a silent client creation.
      self._makeClient(function(err, client) {
        if (!err && client) {
          self._client = client;
          self._processQueue();
        }
        // If user is not logged in, we'll just have to try later.
      });
    });
  },

  /**
   * User is looking at dashboard. Start polling actively, but if user isn't
   * logged in, prompt for them to login via a dialog.
   */
  userActive: function userActive(win) {
    // Stash a reference to the dashboard window in case we need to prompt
    this._dashboardWindow = win;

    if (this._client) {
      this._state = this._ACTIVE;
      return;
    }

    // Make client will first try silent login, if it doesn't work, a popup
    // will be shown in the context of the dashboard. We shouldn't be
    // trying to make a client every time this function is called, there is
    // room for optimization (Bug 750607).
    let self = this;
    this._makeClient(function(err, client) {
      if (err) {
        // Notify user of error (Bug 750610).
        self._log.error("Client not created at Dashboard");
        return;
      }
      self._client = client;
      self._state = self._ACTIVE;
    }, true, win);
  },

  /**
   * User is idle, (either by idle observer, or by not being on the dashboard).
   * When the user is no longer idle and the dashboard is the current active
   * page, a call to userActive MUST be made.
   */
  userIdle: function userIdle() {
    this._state = this._PASSIVE;
    this._dashboardWindow = null;
  },

  /**
   * Initial schedule for the manager. It is the responsibility of the
   * caller who created this object to call this function if it wants to
   * do an initial sync (i.e. upload local apps on a device that has never
   * communicated with AITC before).
   *
   * The callback will be invoked with the number of local apps that were
   * queued to be uploaded, or -1 if this client has already synced and a
   * local upload is not required.
   *
   * Try to schedule PUTs but only if we can get a silent assertion, and if
   * the queue in non-empty, or we've never done a GET (first run).
   */
  initialSchedule: function initialSchedule(cb) {
    let self = this;

    function startProcessQueue(num) {
      self._makeClient(function(err, client) {
        if (!err && client) {
          self._client = client;
          self._processQueue();
          return;
        }
      });
      cb(num);
    }

    // If we've already done a sync with AITC, it means we've already done
    // an initial upload. Resume processing the queue, if there are items in it.
    if (Preferences.get("services.aitc.client.lastModified", "0") != "0") {
      if (this._pending.length) {
        startProcessQueue(-1);
      } else {
        cb(-1);
      }
      return;
    }

    DOMApplicationRegistry.getAllWithoutManifests(function gotAllApps(apps) {
      let done = 0;
      let appids = Object.keys(apps);
      let total = appids.length;
      self._log.info("First run, queuing all local apps: " + total + " found");

      function appQueued(err) {
        if (err) {
          self._log.error("Error queuing app " + apps[appids[done]].origin);
        }

        if (done == total) {
          self._log.info("Finished queuing all initial local apps");
          startProcessQueue(total);
          return;
        }

        let app = apps[appids[done]];
        let obj = {type: "install", app: app, retries: 0, lastTime: 0};

        done += 1;
        self._pending.enqueue(obj, appQueued);
      }
      appQueued();
    });
  },

  /**
   * Poll the AITC server for any changes and process them. It is safe to call
   * this function multiple times. Last caller wins. The function will
   * grab the current user state from _state and act accordingly.
   *
   * Invalid states will cause this function to throw.
   */
  _setPoll: function _setPoll() {
    if (this._state == this._ACTIVE && !this._client) {
      throw new Error("_setPoll(ACTIVE) called without client");
    }
    if (this._state != this._ACTIVE && this._state != this._PASSIVE) {
      throw new Error("_state is invalid " + this._state);
    }

    if (!this._client) {
      // User is not logged in, we cannot do anything.
      self._log.warn("_setPoll called but user not logged in, ignoring");
      return;
    }

    // Check if there are any PUTs pending first.
    if (this._pending.length && !(this._putTimer)) {
      // There are pending PUTs and no timer, so let's process them. GETs will
      // resume after the PUTs finish (see processQueue)
      this._processQueue();
      return;
    }

    // Do one GET soon, but only if user is active.
    let getFreq;
    if (this._state == this._ACTIVE) {
      CommonUtils.nextTick(this._checkServer, this);
      getFreq = PREFS.get("manager.getActiveFreq");
    } else {
      getFreq = PREFS.get("manager.getPassiveFreq");
    }

    // Cancel existing timer, if any.
    if (this._getTimer) {
      this._getTimer.cancel();
      this._getTimer = null;
    }

    // Start the timer for GETs.
    let self = this;
    this._log.info("Starting GET timer");
    this._getTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    this._getTimer.initWithCallback({notify: this._checkServer.bind(this)},
                                    getFreq, Ci.nsITimer.TYPE_REPEATING_SLACK);

    this._log.info("GET timer set, next attempt in " + getFreq + "ms");
  },

  /**
   * Checks if the current token we hold is valid. If not, we obtain a new one
   * and execute the provided func. If a token could not be obtained, func will
   * not be called and an error will be logged.
   */
  _validateToken: function _validateToken(func) {
    let timeSinceLastToken = Date.now() - this._lastTokenTime;
    if (!this._invalidTokenFlag && timeSinceLastToken < this._tokenDuration) {
      this._log.info("Current token is valid");
      func();
      return;
    }

    this._log.info("Current token is invalid");
    let win;
    if (this._state == this.ACTIVE) {
      win = this._dashboardWindow;
    }

    let self = this;
    this._refreshToken(function(err, done) {
      if (!done) {
        self._log.warn("_checkServer could not refresh token, aborting");
        return;
      }
      func(err);
    }, win);
  },

  /**
   * Do a GET check on the server to see if we have any new apps. Abort if
   * there are pending PUTs. If we GET some apps, send to storage for
   * further processing.
   */
  _checkServer: function _checkServer() {
    if (!this._client) {
      throw new Error("_checkServer called without a client");
    }

    if (this._pending.length) {
      this._log.warn("_checkServer aborted because of pending PUTs");
      return;
    }

    let self = this;
    this._validateToken(function validation(err) {
      if (err) {
        self._log.error(err);
      } else {
        self._getApps();
      }
    });
  },

  _getApps: function _getApps() {
    // Do a GET
    this._log.info("Attempting to getApps");

    let self = this;
    this._client.getApps(function gotApps(err, apps) {
      if (err) {
        // Error was logged in client.
        if (err.authfailure) {
          self._invalidTokenFlag = true;
          self._validateToken(function revalidated(err) {
            if (!err) {
              self._getApps();
            }
          });
        } else {
          return;
        }
      }
      if (!apps) {
        // No changes, got 304.
        return;
      }
      if (!apps.length) {
        // Empty array, nothing to process
        self._log.info("No apps found on remote server");
        return;
      }

      // Send list of remote apps to storage to apply locally
      AitcStorage.processApps(apps, function processedApps() {
        self._log.info("processApps completed successfully, changes applied");
      });
    });
  },

  /**
   * Go through list of apps to PUT and attempt each one. If we fail, try
   * again in PUT_FREQ. Will throw if called with an empty, _reschedule()
   * makes sure that we don't.
   */
  _processQueue: function _processQueue() {
    if (!this._client) {
      throw new Error("_processQueue called without a client");
    }
    if (!this._pending.length) {
      throw new Error("_processQueue called with an empty queue");
    }

    if (this._putInProgress) {
      // The network request sent out as a result to the last call to
      // _processQueue still isn't done. A timer is created they all
      // finish to make sure this function is called again if neccessary.
      return;
    }

    let self = this;
    this._validateToken(function validation(err) {
      if (err) {
        self._log.error(err);
      } else {
        self._putApps();
      }
    });
  },

  _putApps: function _putApps() {
    this._putInProgress = true;
    let record = this._pending.peek();

    this._log.info("Processing record type " + record.type);

    let self = this;
    function _clientCallback(err, done) {
      // Send to end of queue if unsuccessful or err.removeFromQueue is false.
      if (err && !err.removeFromQueue) {
        self._log.info("PUT failed, re-adding to queue");
        // Error was logged in client.
        if (err.authfailure) {
          self._invalidTokenFlag = true;
          self._validateToken(function validation(err) {
            if (err) {
              self._log.error("Failed to obtain an updated token");
            }
            _reschedule();
          });
          return;
        }

        // Update retries and time
        record.retries += 1;
        record.lastTime = new Date().getTime();

        // Add updated record to the end of the queue.
        self._pending.enqueue(record, function(err, done) {
          if (err) {
            self._log.error("Enqueue failed " + err);
            _reschedule();
            return;
          }
          // If record was successfully added, remove old record.
          self._pending.dequeue(function(err, done) {
            if (err) {
              self._log.error("Dequeue failed " + err);
            }
            _reschedule();
            return;
          });
        });
      }

      // If succeeded or client told us to remove from queue
      self._log.info("_putApp asked us to remove it from queue");
      self._pending.dequeue(function(err, done) {
        if (err) {
          self._log.error("Dequeue failed " + e);
        }
        _reschedule();
      });
    }

    function _reschedule() {
      // Release PUT lock
      self._putInProgress = false;

      // We just finished PUTting an object, try the next one immediately,
      // but only if haven't tried it already in the last putFreq (ms).
      if (!self._pending.length) {
        // Start GET timer now that we're done with PUTs.
        self._setPoll();
        return;
      }

      let obj = self._pending.peek();
      let cTime = new Date().getTime();
      let freq = PREFS.get("manager.putFreq");

      // We tried this object recently, we'll come back to it later.
      if (obj.lastTime && ((cTime - obj.lastTime) < freq)) {
        self._log.info("Scheduling next processQueue in " + freq);
        CommonUtils.namedTimer(self._processQueue, freq, self, "_putTimer");
        return;
      }

      // Haven't tried this PUT yet, do it immediately.
      self._log.info("Queue non-empty, processing next PUT");
      self._processQueue();
    }

    switch (record.type) {
      case "install":
        this._client.remoteInstall(record.app, _clientCallback);
        break;
      case "uninstall":
        record.app.hidden = true;
        this._client.remoteUninstall(record.app, _clientCallback);
        break;
      default:
        this._log.warn(
          "Unrecognized type " + record.type + " in queue, removing"
        );
        let self = this;
        this._pending.dequeue(function _dequeued(err) {
          if (err) {
            self._log.error("Dequeue of unrecognized app type failed");
          }
          _reschedule();
        });
    }
  },

  /* Obtain a (new) token from the Sagrada token server. If win is is specified,
   * the user will be asked to login via UI, if required. The callback's
   * signature is cb(err, done). If a token is obtained successfully, done will
   * be true and err will be null.
   */
  _refreshToken: function _refreshToken(cb, win) {
    if (!this._client) {
      throw new Error("_refreshToken called without an active client");
    }

    this._log.info("Token refresh requested");

    let self = this;
    function refreshedAssertion(err, assertion) {
      if (!err) {
        self._getToken(assertion, function(err, token) {
          if (err) {
            cb(err, null);
            return;
          }
          self._lastTokenTime = Date.now();
          self._client.updateToken(token);
          self._invalidTokenFlag = false;
          cb(null, true);
        });
        return;
      }

      // Silent refresh was asked for.
      if (!win) {
        cb(err, null);
        return;
      }

      // Prompt user to login.
      self._makeClient(function(err, client) {
        if (err) {
          cb(err, null);
          return;
        }

        // makeClient sets an updated token.
        self._client = client;
        self._invalidTokenFlag = false;
        cb(null, true);
      }, win);
    }

    let options = { audience: DASHBOARD_URL };
    if (this._lastEmail) {
      options.requiredEmail = this._lastEmail;
    } else {
      options.sameEmailAs = MARKETPLACE_URL;
    }
    if (this._premadeToken) {
      this._client.updateToken(this._premadeToken);
      this._tokenDuration = parseInt(this._premadeToken.duration, 10);
      this._lastTokenTime = Date.now();
      this._invalidTokenFlag = false;
      cb(null, true);
    } else {
      BrowserID.getAssertion(refreshedAssertion, options);
    }
  },

  /* Obtain a token from Sagrada token server, given a BrowserID assertion
   * cb(err, token) will be invoked on success or failure.
   */
  _getToken: function _getToken(assertion, cb) {
    let url = PREFS.get("tokenServer.url") + "/1.0/aitc/1.0";
    let client = new TokenServerClient();

    this._log.info("Obtaining token from " + url);

    let self = this;
    try {
      client.getTokenFromBrowserIDAssertion(url, assertion, function(err, tok) {
        self._gotToken(err, tok, cb);
      });
    } catch (e) {
      cb(new Error(e), null);
    }
  },

  // Token recieved from _getToken.
  _gotToken: function _gotToken(err, tok, cb) {
    if (!err) {
      this._log.info("Got token from server: " + JSON.stringify(tok));
      this._tokenDuration = parseInt(tok.duration, 10);
      cb(null, tok);
      return;
    }

    let msg = "Error in _getToken: " + err;
    this._log.error(msg);
    cb(msg, null);
  },

  // Extract the email address from a BrowserID assertion.
  _extractEmail: function _extractEmail(assertion) {
    // Please look the other way while I do this. Thanks.
    let chain = assertion.split("~");
    let len = chain.length;
    if (len < 2) {
      return null;
    }

    try {
      // We need CommonUtils.decodeBase64URL.
      let cert = JSON.parse(atob(
        chain[0].split(".")[1].replace("-", "+", "g").replace("_", "/", "g")
      ));
      return cert.principal.email;
    } catch (e) {
      return null;
    }
  },

  /* To start the AitcClient we need a token, for which we need a BrowserID
   * assertion. If login is true, makeClient will ask the user to login in
   * the context of win. cb is called with (err, client).
   */
  _makeClient: function makeClient(cb, login, win) {
    if (!cb) {
      throw new Error("makeClient called without callback");
    }
    if (login && !win) {
      throw new Error("makeClient called with login as true but no win");
    }

    let self = this;
    let ctxWin = win;
    function processAssertion(val) {
      // Store the email we got the token for so we can refresh.
      self._lastEmail = self._extractEmail(val);
      self._log.info("Got assertion from BrowserID, creating token");
      self._getToken(val, function(err, token) {
        if (err) {
          cb(err, null);
          return;
        }

        // Store when we got the token so we can refresh it as needed.
        self._lastTokenTime = Date.now();

        // We only create one client instance, store values in a pref tree
        cb(null, new AitcClient(
          token, new Preferences("services.aitc.client.")
        ));
      });
    }
    function gotSilentAssertion(err, val) {
      self._log.info("gotSilentAssertion called");
      if (err) {
        // If we were asked to let the user login, do the popup method.
        if (login) {
          self._log.info("Could not obtain silent assertion, retrying login");
          BrowserID.getAssertionWithLogin(function gotAssertion(err, val) {
            if (err) {
              self._log.error(err);
              cb(err, false);
              return;
            }
            processAssertion(val);
          }, ctxWin);
          return;
        }
        self._log.warn("Could not obtain assertion in _makeClient");
        cb(err, false);
      } else {
        processAssertion(val);
      }
    }

    // Check if we can get assertion silently first
    self._log.info("Attempting to obtain assertion silently")
    BrowserID.getAssertion(gotSilentAssertion, {
      audience: DASHBOARD_URL,
      sameEmailAs: MARKETPLACE_URL
    });
  },

};
