/* 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";

this.EXPORTED_SYMBOLS = ["ClientID"];

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

Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Log.jsm");

const LOGGER_NAME = "Toolkit.Telemetry";
const LOGGER_PREFIX = "ClientID::";

XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
                                  "resource://services-common/utils.js");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
				  "resource://gre/modules/osfile.jsm");

XPCOMUtils.defineLazyGetter(this, "gDatareportingPath", () => {
  return OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
});

XPCOMUtils.defineLazyGetter(this, "gStateFilePath", () => {
  return OS.Path.join(gDatareportingPath, "state.json");
});

const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID";

/**
 * Checks if client ID has a valid format.
 *
 * @param {String} id A string containing the client ID.
 * @return {Boolean} True when the client ID has valid format, or False
 * otherwise.
 */
function isValidClientID(id) {
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  return UUID_REGEX.test(id);
}

this.ClientID = Object.freeze({
  /**
   * This returns a promise resolving to the the stable client ID we use for
   * data reporting (FHR & Telemetry). Previously exising FHR client IDs are
   * migrated to this.
   *
   * WARNING: This functionality is duplicated for Android (see GeckoProfile.getClientId
   * for more). There are Java tests (TestGeckoProfile) to ensure the functionality is
   * consistent and Gecko tests to come (bug 1249156). However, THIS IS NOT FOOLPROOF.
   * Be careful when changing this code and, in particular, the underlying file format.
   *
   * @return {Promise<string>} The stable client ID.
   */
  getClientID() {
    return ClientIDImpl.getClientID();
  },

/**
   * Get the client id synchronously without hitting the disk.
   * This returns:
   *  - the current on-disk client id if it was already loaded
   *  - the client id that we cached into preferences (if any)
   *  - null otherwise
   */
  getCachedClientID() {
    return ClientIDImpl.getCachedClientID();
  },

  /**
   * Only used for testing. Invalidates the client ID so that it gets read
   * again from file.
   */
  _reset() {
    return ClientIDImpl._reset();
  },
});

var ClientIDImpl = {
  _clientID: null,
  _loadClientIdTask: null,
  _saveClientIdTask: null,
  _logger: null,

  _loadClientID() {
    if (this._loadClientIdTask) {
      return this._loadClientIdTask;
    }

    this._loadClientIdTask = this._doLoadClientID();
    let clear = () => this._loadClientIdTask = null;
    this._loadClientIdTask.then(clear, clear);
    return this._loadClientIdTask;
  },

  _doLoadClientID: Task.async(function* () {
    // As we want to correlate FHR and telemetry data (and move towards unifying the two),
    // we first moved the ID management from the FHR implementation to the datareporting
    // service, then to a common shared module.
    // Consequently, we try to import an existing FHR ID, so we can keep using it.

    // Try to load the client id from the DRS state file first.
    try {
      let state = yield CommonUtils.readJSON(gStateFilePath);
      if (state && this.updateClientID(state.clientID)) {
        return this._clientID;
      }
    } catch (e) {
      // fall through to next option
    }

    // If we dont have DRS state yet, try to import from the FHR state.
    try {
      let fhrStatePath = OS.Path.join(OS.Constants.Path.profileDir, "healthreport", "state.json");
      let state = yield CommonUtils.readJSON(fhrStatePath);
      if (state && this.updateClientID(state.clientID)) {
        this._saveClientID();
        return this._clientID;
      }
    } catch (e) {
      // fall through to next option
    }

    // We dont have an id from FHR yet, generate a new ID.
    this.updateClientID(CommonUtils.generateUUID());
    this._saveClientIdTask = this._saveClientID();

    // Wait on persisting the id. Otherwise failure to save the ID would result in
    // the client creating and subsequently sending multiple IDs to the server.
    // This would appear as multiple clients submitting similar data, which would
    // result in orphaning.
    yield this._saveClientIdTask;

    return this._clientID;
  }),

  /**
   * Save the client ID to the client ID file.
   *
   * @return {Promise} A promise resolved when the client ID is saved to disk.
   */
  _saveClientID: Task.async(function* () {
    let obj = { clientID: this._clientID };
    yield OS.File.makeDir(gDatareportingPath);
    yield CommonUtils.writeJSON(obj, gStateFilePath);
    this._saveClientIdTask = null;
  }),

  /**
   * This returns a promise resolving to the the stable client ID we use for
   * data reporting (FHR & Telemetry). Previously exising FHR client IDs are
   * migrated to this.
   *
   * @return {Promise<string>} The stable client ID.
   */
  getClientID() {
    if (!this._clientID) {
      return this._loadClientID();
    }

    return Promise.resolve(this._clientID);
  },

  /**
   * Get the client id synchronously without hitting the disk.
   * This returns:
   *  - the current on-disk client id if it was already loaded
   *  - the client id that we cached into preferences (if any)
   *  - null otherwise
   */
  getCachedClientID() {
    if (this._clientID) {
      // Already loaded the client id from disk.
      return this._clientID;
    }

    // Not yet loaded, return the cached client id if we have one.
    let id = Preferences.get(PREF_CACHED_CLIENTID, null);
    if (id === null) {
      return null;
    }
    if (!isValidClientID(id)) {
      this._log.error("getCachedClientID - invalid client id in preferences, resetting", id);
      Preferences.reset(PREF_CACHED_CLIENTID);
      return null;
    }
    return id;
  },

  /*
   * Resets the provider. This is for testing only.
   */
  _reset: Task.async(function* () {
    yield this._loadClientIdTask;
    yield this._saveClientIdTask;
    this._clientID = null;
  }),

  /**
   * Sets the client id to the given value and updates the value cached in
   * preferences only if the given id is a valid.
   *
   * @param {String} id A string containing the client ID.
   * @return {Boolean} True when the client ID has valid format, or False
   * otherwise.
   */
  updateClientID(id) {
    if (!isValidClientID(id)) {
      this._log.error("updateClientID - invalid client ID", id);
      return false;
    }

    this._clientID = id;
    Preferences.set(PREF_CACHED_CLIENTID, this._clientID);
    return true;
  },

  /**
   * A helper for getting access to telemetry logger.
   */
  get _log() {
    if (!this._logger) {
      this._logger = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX);
    }

    return this._logger;
  },
};
