/* Copyright 2015 The Chromium Authors. All rights reserved.
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file. */

/**
 * @fileoverview
 * 'settings-prefs' exposes a singleton model of Chrome settings and
 * preferences, which listens to changes to Chrome prefs whitelisted in
 * chrome.settingsPrivate. When changing prefs in this element's 'prefs'
 * property via the UI, the singleton model tries to set those preferences in
 * Chrome. Whether or not the calls to settingsPrivate.setPref succeed, 'prefs'
 * is eventually consistent with the Chrome pref store.
 */

(function() {
  'use strict';

  /**
   * Checks whether two values are recursively equal. Only compares serializable
   * data (primitives, serializable arrays and serializable objects).
   * @param {*} val1 Value to compare.
   * @param {*} val2 Value to compare with val1.
   * @return {boolean} True if the values are recursively equal.
   */
  function deepEqual(val1, val2) {
    if (val1 === val2)
      return true;

    if (Array.isArray(val1) || Array.isArray(val2)) {
      if (!Array.isArray(val1) || !Array.isArray(val2))
        return false;
      return arraysEqual(/** @type {!Array} */(val1),
                         /** @type {!Array} */(val2));
    }

    if (val1 instanceof Object && val2 instanceof Object)
      return objectsEqual(val1, val2);

    return false;
  }

  /**
   * @param {!Array} arr1
   * @param {!Array} arr2
   * @return {boolean} True if the arrays are recursively equal.
   */
  function arraysEqual(arr1, arr2) {
    if (arr1.length != arr2.length)
      return false;

    for (var i = 0; i < arr1.length; i++) {
      if (!deepEqual(arr1[i], arr2[i]))
        return false;
    }

    return true;
  }

  /**
   * @param {!Object} obj1
   * @param {!Object} obj2
   * @return {boolean} True if the objects are recursively equal.
   */
  function objectsEqual(obj1, obj2) {
    var keys1 = Object.keys(obj1);
    var keys2 = Object.keys(obj2);
    if (keys1.length != keys2.length)
      return false;

    for (var i = 0; i < keys1.length; i++) {
      var key = keys1[i];
      if (!deepEqual(obj1[key], obj2[key]))
        return false;
    }

    return true;
  }

  /**
   * Returns a recursive copy of the value.
   * @param {*} val Value to copy. Should be a primitive or only contain
   *     serializable data (primitives, serializable arrays and
   *     serializable objects).
   * @return {*} A deep copy of the value.
   */
  function deepCopy(val) {
    if (!(val instanceof Object))
      return val;
    return Array.isArray(val) ? deepCopyArray(/** @type {!Array} */(val)) :
        deepCopyObject(val);
  };

  /**
   * @param {!Array} arr
   * @return {!Array} Deep copy of the array.
   */
  function deepCopyArray(arr) {
    var copy = [];
    for (var i = 0; i < arr.length; i++)
      copy.push(deepCopy(arr[i]));
    return copy;
  }

  /**
   * @param {!Object} obj
   * @return {!Object} Deep copy of the object.
   */
  function deepCopyObject(obj) {
    var copy = {};
    var keys = Object.keys(obj);
    for (var i = 0; i < keys.length; i++) {
      var key = keys[i];
      copy[key] = deepCopy(obj[key]);
    }
    return copy;
  }

  Polymer({
    is: 'settings-prefs',

    properties: {
      /**
       * Object containing all preferences, for use by Polymer controls.
       * @type {Object|undefined}
       */
      prefs: {
        type: Object,
        notify: true,
      },

      /**
       * Map of pref keys to values representing the state of the Chrome
       * pref store as of the last update from the API.
       * @type {Object<*>}
       * @private
       */
      lastPrefValues_: {
        type: Object,
        value: function() { return {}; },
      },
    },

    observers: [
      'prefsChanged_(prefs.*)',
    ],

    /** @type {SettingsPrivate} */
    settingsApi_: /** @type {SettingsPrivate} */(chrome.settingsPrivate),

    created: function() {
      if (!CrSettingsPrefs.deferInitialization)
        this.initialize();
    },

    /**
     * @param {SettingsPrivate=} opt_settingsApi SettingsPrivate implementation
     *     to use (chrome.settingsPrivate by default).
     */
    initialize: function(opt_settingsApi) {
      // Only initialize once (or after resetForTesting() is called).
      if (this.initialized_)
        return;
      this.initialized_ = true;

      if (opt_settingsApi)
        this.settingsApi_ = opt_settingsApi;

      /** @private {function(!Array<!chrome.settingsPrivate.PrefObject>)} */
      this.boundPrefsChanged_ = this.onSettingsPrivatePrefsChanged_.bind(this);
      this.settingsApi_.onPrefsChanged.addListener(this.boundPrefsChanged_);
      this.settingsApi_.getAllPrefs(
          this.onSettingsPrivatePrefsFetched_.bind(this));
    },

    /**
     * @param {!{path: string}} e
     * @private
     */
    prefsChanged_: function(e) {
      // |prefs| can be directly set or unset in tests.
      if (!CrSettingsPrefs.isInitialized || e.path == 'prefs')
        return;

      var key = this.getPrefKeyFromPath_(e.path);
      var prefStoreValue = this.lastPrefValues_[key];

      var prefObj = /** @type {chrome.settingsPrivate.PrefObject} */(
          this.get(key, this.prefs));

      // If settingsPrivate already has this value, ignore it. (Otherwise,
      // a change event from settingsPrivate could make us call
      // settingsPrivate.setPref and potentially trigger an IPC loop.)
      if (!deepEqual(prefStoreValue, prefObj.value)) {
        this.settingsApi_.setPref(
            key,
            prefObj.value,
            /* pageId */ '',
            /* callback */ this.setPrefCallback_.bind(this, key));
      }
    },

    /**
     * Called when prefs in the underlying Chrome pref store are changed.
     * @param {!Array<!chrome.settingsPrivate.PrefObject>} prefs
     *     The prefs that changed.
     * @private
     */
    onSettingsPrivatePrefsChanged_: function(prefs) {
      if (CrSettingsPrefs.isInitialized)
        this.updatePrefs_(prefs);
    },

    /**
     * Called when prefs are fetched from settingsPrivate.
     * @param {!Array<!chrome.settingsPrivate.PrefObject>} prefs
     * @private
     */
    onSettingsPrivatePrefsFetched_: function(prefs) {
      this.updatePrefs_(prefs);
      CrSettingsPrefs.setInitialized();
    },

    /**
     * Checks the result of calling settingsPrivate.setPref.
     * @param {string} key The key used in the call to setPref.
     * @param {boolean} success True if setting the pref succeeded.
     * @private
     */
    setPrefCallback_: function(key, success) {
      if (!success)
        this.refresh(key);
    },

    /**
     * Get the current pref value from chrome.settingsPrivate to ensure the UI
     * stays up to date.
     * @param {string} key
     */
    refresh: function(key) {
      this.settingsApi_.getPref(key, function(pref) {
        this.updatePrefs_([pref]);
      }.bind(this));
    },

    /**
     * Updates the prefs model with the given prefs.
     * @param {!Array<!chrome.settingsPrivate.PrefObject>} newPrefs
     * @private
     */
    updatePrefs_: function(newPrefs) {
      // Use the existing prefs object or create it.
      var prefs = this.prefs || {};
      newPrefs.forEach(function(newPrefObj) {
        // Use the PrefObject from settingsPrivate to create a copy in
        // lastPrefValues_ at the pref's key.
        this.lastPrefValues_[newPrefObj.key] = deepCopy(newPrefObj.value);

        if (!deepEqual(this.get(newPrefObj.key, prefs), newPrefObj)) {
          // Add the pref to |prefs|.
          cr.exportPath(newPrefObj.key, newPrefObj, prefs);
          // If this.prefs already exists, notify listeners of the change.
          if (prefs == this.prefs)
            this.notifyPath('prefs.' + newPrefObj.key, newPrefObj);
        }
      }, this);
      if (!this.prefs)
        this.prefs = prefs;
    },

    /**
     * Given a 'property-changed' path, returns the key of the preference the
     * path refers to. E.g., if the path of the changed property is
     * 'prefs.search.suggest_enabled.value', the key of the pref that changed is
     * 'search.suggest_enabled'.
     * @param {string} path
     * @return {string}
     * @private
     */
    getPrefKeyFromPath_: function(path) {
      // Skip the first token, which refers to the member variable (this.prefs).
      var parts = path.split('.');
      assert(parts.shift() == 'prefs', "Path doesn't begin with 'prefs'");

      for (let i = 1; i <= parts.length; i++) {
        let key = parts.slice(0, i).join('.');
        // The lastPrefValues_ keys match the pref keys.
        if (this.lastPrefValues_.hasOwnProperty(key))
          return key;
      }
      return '';
    },

    /**
     * Resets the element so it can be re-initialized with a new prefs state.
     */
    resetForTesting: function() {
      if (!this.initialized_)
        return;
      this.prefs = undefined;
      this.lastPrefValues_ = {};
      this.initialized_ = false;
      // Remove the listener added in initialize().
      this.settingsApi_.onPrefsChanged.removeListener(this.boundPrefsChanged_);
      this.settingsApi_ =
          /** @type {SettingsPrivate} */(chrome.settingsPrivate);
    },
  });
})();
