/* 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 (let 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) {
  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);
  if (keys1.length != keys2.length) {
    return false;
  }

  for (let i = 0; i < keys1.length; i++) {
    const 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) {
  const copy = [];
  for (let 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) {
  const copy = {};
  const keys = Object.keys(obj);
  for (let i = 0; i < keys.length; i++) {
    const 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),

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

  /** @override */
  detached: function() {
    CrSettingsPrefs.resetForTesting();
  },

  /**
   * @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;
    }

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

    const 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, pref => {
      this.updatePrefs_([pref]);
    });
  },

  /**
   * 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.
    const 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).
    const parts = path.split('.');
    assert(parts.shift() == 'prefs', 'Path doesn\'t begin with \'prefs\'');

    for (let i = 1; i <= parts.length; i++) {
      const 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);
  },
});
})();
