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

package org.mozilla.gecko.sync;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.mozilla.gecko.background.common.PrefsBranch;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.sync.crypto.KeyBundle;
import org.mozilla.gecko.sync.crypto.PersistedCrypto5Keys;
import org.mozilla.gecko.sync.net.AuthHeaderProvider;
import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;

import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;

public class SyncConfiguration {
  private static final String LOG_TAG = "SyncConfiguration";

  // These must be set in GlobalSession's constructor.
  public URI             clusterURL;
  public KeyBundle       syncKeyBundle;

  public CollectionKeys  collectionKeys;
  public InfoCollections infoCollections;
  public MetaGlobal      metaGlobal;
  public String          syncID;

  protected final String username;

  /**
   * Persisted collection of enabledEngineNames.
   * <p>
   * Can contain engines Android Sync is not currently aware of, such as "prefs"
   * or "addons".
   * <p>
   * Copied from latest downloaded meta/global record and used to generate a
   * fresh meta/global record for upload.
   */
  public Set<String> enabledEngineNames;
  public Set<String> declinedEngineNames = new HashSet<String>();

  /**
   * Names of stages to sync <it>this sync</it>, or <code>null</code> to sync
   * all known stages.
   * <p>
   * Generated <it>each sync</it> from extras bundle passed to
   * <code>SyncAdapter.onPerformSync</code> and not persisted.
   * <p>
   * Not synchronized! Set this exactly once per global session and don't modify
   * it -- especially not from multiple threads.
   */
  public Collection<String> stagesToSync;

  /**
   * Engines whose sync state has been modified by the user through
   * SelectEnginesActivity, where each key-value pair is an engine name and
   * its sync state.
   *
   * This differs from <code>enabledEngineNames</code> in that
   * <code>enabledEngineNames</code> reflects the downloaded meta/global,
   * whereas <code>userSelectedEngines</code> stores the differences in engines to
   * sync that the user has selected.
   *
   * Each engine stage will check for engine changes at the beginning of the
   * stage.
   *
   * If no engine sync state changes have been made by the user, userSelectedEngines
   * will be null, and Sync will proceed normally.
   *
   * If the user has made changes to engine syncing state, each engine will sync
   * according to the sync state specified in userSelectedEngines and propagate that
   * state to meta/global, to be uploaded.
   */
  public Map<String, Boolean> userSelectedEngines;
  public long userSelectedEnginesTimestamp;

  public SharedPreferences prefs;

  protected final AuthHeaderProvider authHeaderProvider;

  public static final String PREF_PREFS_VERSION = "prefs.version";
  public static final long CURRENT_PREFS_VERSION = 1;

  public static final String CLIENTS_COLLECTION_TIMESTAMP = "serverClientsTimestamp";  // When the collection was touched.
  public static final String CLIENT_RECORD_TIMESTAMP = "serverClientRecordTimestamp";  // When our record was touched.
  public static final String MIGRATION_SENTINEL_CHECK_TIMESTAMP = "migrationSentinelCheckTimestamp";  // When we last looked in meta/fxa_credentials.

  public static final String PREF_CLUSTER_URL = "clusterURL";
  public static final String PREF_SYNC_ID = "syncID";

  public static final String PREF_ENABLED_ENGINE_NAMES = "enabledEngineNames";
  public static final String PREF_DECLINED_ENGINE_NAMES = "declinedEngineNames";
  public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC = "userSelectedEngines";
  public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP = "userSelectedEnginesTimestamp";

  public static final String PREF_CLUSTER_URL_IS_STALE = "clusterurlisstale";

  public static final String PREF_ACCOUNT_GUID = "account.guid";
  public static final String PREF_CLIENT_NAME = "account.clientName";
  public static final String PREF_NUM_CLIENTS = "account.numClients";
  public static final String PREF_CLIENT_DATA_TIMESTAMP = "account.clientDataTimestamp";

  private static final String API_VERSION = "1.5";

  public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs) {
    this.username = username;
    this.authHeaderProvider = authHeaderProvider;
    this.prefs = prefs;
    this.loadFromPrefs(prefs);
  }

  public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs, KeyBundle syncKeyBundle) {
    this(username, authHeaderProvider, prefs);
    this.syncKeyBundle = syncKeyBundle;
  }

  public String getAPIVersion() {
    return API_VERSION;
  }

  public SharedPreferences getPrefs() {
    return this.prefs;
  }

  /**
   * Valid engines supported by Android Sync.
   *
   * @return Set<String> of valid engine names that Android Sync implements.
   */
  public static Set<String> validEngineNames() {
    Set<String> engineNames = new HashSet<String>();
    for (Stage stage : Stage.getNamedStages()) {
      engineNames.add(stage.getRepositoryName());
    }
    return engineNames;
  }

  /**
   * Return a convenient accessor for part of prefs.
   * @return
   *        A PrefsBranch object representing this
   *        section of the preferences space.
   */
  public PrefsBranch getBranch(String prefix) {
    return new PrefsBranch(this.getPrefs(), prefix);
  }

  /**
   * Gets the engine names that are enabled, declined, or other (depending on pref) in meta/global.
   *
   * @param prefs
   *          SharedPreferences that the engines are associated with.
   * @param pref
   *          The preference name to use. E.g, PREF_ENABLED_ENGINE_NAMES.
   * @return Set<String> of the enabled engine names if they have been stored,
   *         or null otherwise.
   */
  protected static Set<String> getEngineNamesFromPref(SharedPreferences prefs, String pref) {
    final String json = prefs.getString(pref, null);
    if (json == null) {
      return null;
    }
    try {
      final ExtendedJSONObject o = new ExtendedJSONObject(json);
      return new HashSet<String>(o.keySet());
    } catch (Exception e) {
      return null;
    }
  }

  /**
   * Returns the set of engine names that the user has enabled. If none
   * have been stored in prefs, <code>null</code> is returned.
   */
  public static Set<String> getEnabledEngineNames(SharedPreferences prefs) {
      return getEngineNamesFromPref(prefs, PREF_ENABLED_ENGINE_NAMES);
  }

  /**
   * Returns the set of engine names that the user has declined.
   */
  public static Set<String> getDeclinedEngineNames(SharedPreferences prefs) {
    final Set<String> names = getEngineNamesFromPref(prefs, PREF_DECLINED_ENGINE_NAMES);
    if (names == null) {
        return new HashSet<String>();
    }
    return names;
  }

  /**
   * Gets the engines whose sync states have been changed by the user through the
   * SelectEnginesActivity.
   *
   * @param prefs
   *          SharedPreferences of account that the engines are associated with.
   * @return Map<String, Boolean> of changed engines. Key is the lower-cased
   *         engine name, Value is the new sync state.
   */
  public static Map<String, Boolean> getUserSelectedEngines(SharedPreferences prefs) {
    String json = prefs.getString(PREF_USER_SELECTED_ENGINES_TO_SYNC, null);
    if (json == null) {
      return null;
    }
    try {
      ExtendedJSONObject o = new ExtendedJSONObject(json);
      Map<String, Boolean> map = new HashMap<String, Boolean>();
      for (Entry<String, Object> e : o.entrySet()) {
        String key = e.getKey();
        Boolean value = (Boolean) e.getValue();
        map.put(key, value);
        // Forms depends on history. Add forms if history is selected.
        if ("history".equals(key)) {
          map.put("forms", value);
        }
      }
      // Sanity check: remove forms if history does not exist.
      if (!map.containsKey("history")) {
        map.remove("forms");
      }
      return map;
    } catch (Exception e) {
      return null;
    }
  }

  /**
   * Store a Map of engines and their sync states to prefs.
   *
   * Any engine that's disabled in the input is also recorded
   * as a declined engine, overwriting the stored values.
   *
   * @param prefs
   *          SharedPreferences that the engines are associated with.
   * @param selectedEngines
   *          Map<String, Boolean> of engine name to sync state
   */
  public static void storeSelectedEnginesToPrefs(SharedPreferences prefs, Map<String, Boolean> selectedEngines) {
    ExtendedJSONObject jObj = new ExtendedJSONObject();
    HashSet<String> declined = new HashSet<String>();
    for (Entry<String, Boolean> e : selectedEngines.entrySet()) {
      final Boolean enabled = e.getValue();
      final String engine = e.getKey();
      jObj.put(engine, enabled);
      if (!enabled) {
        declined.add(engine);
      }
    }

    // Our history checkbox drives form history, too.
    // We don't need to do this for enablement: that's done at retrieval time.
    if (selectedEngines.containsKey("history") && !selectedEngines.get("history")) {
      declined.add("forms");
    }

    String json = jObj.toJSONString();
    long currentTime = System.currentTimeMillis();
    Editor edit = prefs.edit();
    edit.putString(PREF_USER_SELECTED_ENGINES_TO_SYNC, json);
    edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declined));
    edit.putLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, currentTime);
    Logger.error(LOG_TAG, "Storing user-selected engines at [" + currentTime + "].");
    edit.commit();
  }

  public void loadFromPrefs(SharedPreferences prefs) {
    if (prefs.contains(PREF_CLUSTER_URL)) {
      String u = prefs.getString(PREF_CLUSTER_URL, null);
      try {
        clusterURL = new URI(u);
        Logger.trace(LOG_TAG, "Set clusterURL from bundle: " + u);
      } catch (URISyntaxException e) {
        Logger.warn(LOG_TAG, "Ignoring bundle clusterURL (" + u + "): invalid URI.", e);
      }
    }
    if (prefs.contains(PREF_SYNC_ID)) {
      syncID = prefs.getString(PREF_SYNC_ID, null);
      Logger.trace(LOG_TAG, "Set syncID from bundle: " + syncID);
    }
    enabledEngineNames = getEnabledEngineNames(prefs);
    declinedEngineNames = getDeclinedEngineNames(prefs);
    userSelectedEngines = getUserSelectedEngines(prefs);
    userSelectedEnginesTimestamp = prefs.getLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, 0);
    // We don't set crypto/keys here because we need the syncKeyBundle to decrypt the JSON
    // and we won't have it on construction.
    // TODO: MetaGlobal, password, infoCollections.
  }

  public void persistToPrefs() {
    this.persistToPrefs(this.getPrefs());
  }

  private static String setToJSONObjectString(Set<String> set) {
    ExtendedJSONObject o = new ExtendedJSONObject();
    for (String name : set) {
      o.put(name, 0);
    }
    return o.toJSONString();
  }

  public void persistToPrefs(SharedPreferences prefs) {
    Editor edit = prefs.edit();
    if (clusterURL == null) {
      edit.remove(PREF_CLUSTER_URL);
    } else {
      edit.putString(PREF_CLUSTER_URL, clusterURL.toASCIIString());
    }
    if (syncID != null) {
      edit.putString(PREF_SYNC_ID, syncID);
    }
    if (enabledEngineNames == null) {
      edit.remove(PREF_ENABLED_ENGINE_NAMES);
    } else {
      edit.putString(PREF_ENABLED_ENGINE_NAMES, setToJSONObjectString(enabledEngineNames));
    }
    if (declinedEngineNames == null || declinedEngineNames.isEmpty()) {
      edit.remove(PREF_DECLINED_ENGINE_NAMES);
    } else {
      edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declinedEngineNames));
    }
    if (userSelectedEngines == null) {
      edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC);
      edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP);
    }
    // Don't bother saving userSelectedEngines - these should only be changed by
    // SelectEnginesActivity.
    edit.commit();
    // TODO: keys.
  }

  public AuthHeaderProvider getAuthHeaderProvider() {
    return authHeaderProvider;
  }

  public CollectionKeys getCollectionKeys() {
    return collectionKeys;
  }

  public void setCollectionKeys(CollectionKeys k) {
    collectionKeys = k;
  }

  /**
   * Return path to storage endpoint without trailing slash.
   *
   * @return storage endpoint without trailing slash.
   */
  public String storageURL() {
    return clusterURL + "/storage";
  }

  protected String infoBaseURL() {
    return clusterURL + "/info/";
  }

  public String infoCollectionsURL() {
    return infoBaseURL() + "collections";
  }

  public String infoCollectionCountsURL() {
    return infoBaseURL() + "collection_counts";
  }

  public String metaURL() {
    return storageURL() + "/meta/global";
  }

  public URI collectionURI(String collection) throws URISyntaxException {
    return new URI(storageURL() + "/" + collection);
  }

  public URI collectionURI(String collection, boolean full) throws URISyntaxException {
    // Do it this way to make it easier to add more params later.
    // It's pretty ugly, I'll grant.
    boolean anyParams = full;
    String  uriParams = "";
    if (anyParams) {
      StringBuilder params = new StringBuilder("?");
      if (full) {
        params.append("full=1");
      }
      uriParams = params.toString();
    }
    String uri = storageURL() + "/" + collection + uriParams;
    return new URI(uri);
  }

  public URI wboURI(String collection, String id) throws URISyntaxException {
    return new URI(storageURL() + "/" + collection + "/" + id);
  }

  public URI keysURI() throws URISyntaxException {
    return wboURI("crypto", "keys");
  }

  public URI getClusterURL() {
    return clusterURL;
  }

  public String getClusterURLString() {
    if (clusterURL == null) {
      return null;
    }
    return clusterURL.toASCIIString();
  }

  public void setClusterURL(URI u) {
    this.clusterURL = u;
  }

  /**
   * Used for direct management of related prefs.
   */
  public Editor getEditor() {
    return this.getPrefs().edit();
  }

  /**
   * We persist two different clients timestamps: our own record's,
   * and the timestamp for the collection.
   */
  public void persistServerClientRecordTimestamp(long timestamp) {
    getEditor().putLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, timestamp).commit();
  }

  public long getPersistedServerClientRecordTimestamp() {
    return getPrefs().getLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, 0L);
  }

  public void persistServerClientsTimestamp(long timestamp) {
    getEditor().putLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, timestamp).commit();
  }

  public long getPersistedServerClientsTimestamp() {
    return getPrefs().getLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, 0L);
  }

  public void persistLastMigrationSentinelCheckTimestamp(long timestamp) {
    getEditor().putLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, timestamp).commit();
  }

  public long getLastMigrationSentinelCheckTimestamp() {
    return getPrefs().getLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, 0L);
  }

  public void purgeCryptoKeys() {
    if (collectionKeys != null) {
      collectionKeys.clear();
    }
    persistedCryptoKeys().purge();
  }

  public void purgeMetaGlobal() {
    metaGlobal = null;
    persistedMetaGlobal().purge();
  }

  public PersistedCrypto5Keys persistedCryptoKeys() {
    return new PersistedCrypto5Keys(getPrefs(), syncKeyBundle);
  }

  public PersistedMetaGlobal persistedMetaGlobal() {
    return new PersistedMetaGlobal(getPrefs());
  }
}
