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

import java.io.File;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;

import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.sync.CredentialException;
import org.mozilla.gecko.sync.ExtendedJSONObject;
import org.mozilla.gecko.background.common.GlobalConstants;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.sync.SyncConstants;
import org.mozilla.gecko.sync.SyncConfiguration;
import org.mozilla.gecko.sync.ThreadPool;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.config.AccountPickler;
import org.mozilla.gecko.sync.repositories.android.RepoUtils;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.AsyncTask;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;

/**
 * This class contains utilities that are of use to Fennec
 * and Sync setup activities.
 * <p>
 * Do not break these APIs without correcting upstream code!
 */
public class SyncAccounts {
  private static final String LOG_TAG = "SyncAccounts";

  private static final String MOTO_BLUR_SETTINGS_ACTIVITY = "com.motorola.blur.settings.AccountsAndServicesPreferenceActivity";
  private static final String MOTO_BLUR_PACKAGE           = "com.motorola.blur.setup";

  public final static String DEFAULT_SERVER = "https://auth.services.mozilla.com/";

  /**
   * Return Sync accounts.
   *
   * @param c
   *          Android context.
   * @return Sync accounts.
   */
  public static Account[] syncAccounts(final Context c) {
    return AccountManager.get(c).getAccountsByType(SyncConstants.ACCOUNTTYPE_SYNC);
  }

  /**
   * Returns true if a Sync account is set up, or we have a pickled Sync account
   * on disk that should be un-pickled (Bug 769745). If we have a pickled Sync
   * account, try to un-pickle it and create the corresponding Sync account.
   * <p>
   * Do not call this method from the main thread.
   */
  public static boolean syncAccountsExist(Context c) {
    final boolean accountsExist = AccountManager.get(c).getAccountsByType(SyncConstants.ACCOUNTTYPE_SYNC).length > 0;
    if (accountsExist) {
      return true;
    }

    final File file = c.getFileStreamPath(Constants.ACCOUNT_PICKLE_FILENAME);
    if (!file.exists()) {
      return false;
    }

    // There is a small race window here: if the user creates a new Sync account
    // between our checks, this could erroneously report that no Sync accounts
    // exist.
    final Account account = AccountPickler.unpickle(c, Constants.ACCOUNT_PICKLE_FILENAME);
    return (account != null);
  }

  /**
   * This class provides background-thread abstracted access to whether a
   * Firefox Sync account has been set up on this device.
   * <p>
   * Subclass this task and override `onPostExecute` to act on the result.
   */
  public static class AccountsExistTask extends AsyncTask<Context, Void, Boolean> {
    @Override
    protected Boolean doInBackground(Context... params) {
      Context c = params[0];
      return syncAccountsExist(c);
    }
  }

  /**
   * This class encapsulates the parameters needed to create a new Firefox Sync
   * account.
   */
  public static class SyncAccountParameters {
    public final Context context;
    public final AccountManager accountManager;


    public final String username;   // services.sync.account
    public final String syncKey;    // in password manager: "chrome://weave (Mozilla Services Encryption Passphrase)"
    public final String password;   // in password manager: "chrome://weave (Mozilla Services Password)"
    public final String serverURL;  // services.sync.serverURL
    public final String clusterURL; // services.sync.clusterURL
    public final String clientName; // services.sync.client.name
    public final String clientGuid; // services.sync.client.GUID

    /**
     * Encapsulate the parameters needed to create a new Firefox Sync account.
     *
     * @param context
     *          the current <code>Context</code>; cannot be null.
     * @param accountManager
     *          an <code>AccountManager</code> instance to use; if null, get it
     *          from <code>context</code>.
     * @param username
     *          the desired username; cannot be null.
     * @param syncKey
     *          the desired sync key; cannot be null.
     * @param password
     *          the desired password; cannot be null.
     * @param serverURL
     *          the server URL to use; if null, use the default.
     * @param clusterURL
     *          the cluster URL to use; if null, a fresh cluster URL will be
     *          retrieved from the server during the next sync.
     * @param clientName
     *          the client name; if null, a fresh client record will be uploaded
     *          to the server during the next sync.
     * @param clientGuid
     *          the client GUID; if null, a fresh client record will be uploaded
     *          to the server during the next sync.
     */
    public SyncAccountParameters(Context context, AccountManager accountManager,
        String username, String syncKey, String password,
        String serverURL, String clusterURL,
        String clientName, String clientGuid) {
      if (context == null) {
        throw new IllegalArgumentException("Null context passed to SyncAccountParameters constructor.");
      }
      if (username == null) {
        throw new IllegalArgumentException("Null username passed to SyncAccountParameters constructor.");
      }
      if (syncKey == null) {
        throw new IllegalArgumentException("Null syncKey passed to SyncAccountParameters constructor.");
      }
      if (password == null) {
        throw new IllegalArgumentException("Null password passed to SyncAccountParameters constructor.");
      }
      this.context = context;
      this.accountManager = accountManager;
      this.username = username;
      this.syncKey = syncKey;
      this.password = password;
      this.serverURL = serverURL;
      this.clusterURL = clusterURL;
      this.clientName = clientName;
      this.clientGuid = clientGuid;
    }

    public SyncAccountParameters(Context context, AccountManager accountManager,
        String username, String syncKey, String password, String serverURL) {
      this(context, accountManager, username, syncKey, password, serverURL, null, null, null);
    }

    public SyncAccountParameters(final Context context, final AccountManager accountManager, final ExtendedJSONObject o) {
      this(context, accountManager,
          o.getString(Constants.JSON_KEY_ACCOUNT),
          o.getString(Constants.JSON_KEY_SYNCKEY),
          o.getString(Constants.JSON_KEY_PASSWORD),
          o.getString(Constants.JSON_KEY_SERVER),
          o.getString(Constants.JSON_KEY_CLUSTER),
          o.getString(Constants.JSON_KEY_CLIENT_NAME),
          o.getString(Constants.JSON_KEY_CLIENT_GUID));
    }

    public ExtendedJSONObject asJSON() {
      final ExtendedJSONObject o = new ExtendedJSONObject();
      o.put(Constants.JSON_KEY_ACCOUNT, username);
      o.put(Constants.JSON_KEY_PASSWORD, password);
      o.put(Constants.JSON_KEY_SERVER, serverURL);
      o.put(Constants.JSON_KEY_SYNCKEY, syncKey);
      o.put(Constants.JSON_KEY_CLUSTER, clusterURL);
      o.put(Constants.JSON_KEY_CLIENT_NAME, clientName);
      o.put(Constants.JSON_KEY_CLIENT_GUID, clientGuid);
      return o;
    }
  }

  /**
   * This class provides background-thread abstracted access to creating a
   * Firefox Sync account.
   * <p>
   * Subclass this task and override `onPostExecute` to act on the result. The
   * <code>Result</code> (of type <code>Account</code>) is null if an error
   * occurred and the account could not be added.
   */
  public static class CreateSyncAccountTask extends AsyncTask<SyncAccountParameters, Void, Account> {
    protected final boolean syncAutomatically;

    public CreateSyncAccountTask() {
      this(true);
    }

    public CreateSyncAccountTask(final boolean syncAutomically) {
      this.syncAutomatically = syncAutomically;
    }

    @Override
    protected Account doInBackground(SyncAccountParameters... params) {
      SyncAccountParameters syncAccount = params[0];
      try {
        return createSyncAccount(syncAccount, syncAutomatically);
      } catch (Exception e) {
        Log.e(SyncConstants.GLOBAL_LOG_TAG, "Unable to create account.", e);
        return null;
      }
    }
  }

  /**
   * Create a sync account, clearing any existing preferences, and set it to
   * sync automatically.
   * <p>
   * Do not call this method from the main thread.
   *
   * @param syncAccount
   *          parameters of the account to be created.
   * @return created <code>Account</code>, or null if an error occurred and the
   *         account could not be added.
   */
  public static Account createSyncAccount(SyncAccountParameters syncAccount) {
    return createSyncAccount(syncAccount, true, true);
  }

  /**
   * Create a sync account, clearing any existing preferences.
   * <p>
   * Do not call this method from the main thread.
   * <p>
   * Intended for testing; use
   * <code>createSyncAccount(SyncAccountParameters)</code> instead.
   *
   * @param syncAccount
   *          parameters of the account to be created.
   * @param syncAutomatically
   *          whether to start syncing this Account automatically (
   *          <code>false</code> for test accounts).
   * @return created Android <code>Account</code>, or null if an error occurred
   *         and the account could not be added.
   */
  public static Account createSyncAccount(SyncAccountParameters syncAccount,
      boolean syncAutomatically) {
    return createSyncAccount(syncAccount, syncAutomatically, true);
  }

  public static Account createSyncAccountPreservingExistingPreferences(SyncAccountParameters syncAccount,
      boolean syncAutomatically) {
    return createSyncAccount(syncAccount, syncAutomatically, false);
  }

  /**
   * Create a sync account.
   * <p>
   * Do not call this method from the main thread.
   * <p>
   * Intended for testing; use
   * <code>createSyncAccount(SyncAccountParameters)</code> instead.
   *
   * @param syncAccount
   *          parameters of the account to be created.
   * @param syncAutomatically
   *          whether to start syncing this Account automatically (
   *          <code>false</code> for test accounts).
   * @param clearPreferences
   *          <code>true</code> to clear existing preferences before creating.
   * @return created Android <code>Account</code>, or null if an error occurred
   *         and the account could not be added.
   */
  protected static Account createSyncAccount(SyncAccountParameters syncAccount,
      boolean syncAutomatically, boolean clearPreferences) {
    final Context context = syncAccount.context;
    final AccountManager accountManager = (syncAccount.accountManager == null) ?
          AccountManager.get(syncAccount.context) : syncAccount.accountManager;
    final String username  = syncAccount.username;
    final String syncKey   = syncAccount.syncKey;
    final String password  = syncAccount.password;
    final String serverURL = (syncAccount.serverURL == null) ?
        DEFAULT_SERVER : syncAccount.serverURL;

    Logger.debug(LOG_TAG, "Using account manager " + accountManager);
    if (!RepoUtils.stringsEqual(syncAccount.serverURL, DEFAULT_SERVER)) {
      Logger.info(LOG_TAG, "Setting explicit server URL: " + serverURL);
    }

    final Account account = new Account(username, SyncConstants.ACCOUNTTYPE_SYNC);
    final Bundle userbundle = new Bundle();

    // Add sync key and server URL.
    userbundle.putString(Constants.OPTION_SYNCKEY, syncKey);
    userbundle.putString(Constants.OPTION_SERVER, serverURL);
    Logger.debug(LOG_TAG, "Adding account for " + SyncConstants.ACCOUNTTYPE_SYNC);
    boolean result = false;
    try {
      result = accountManager.addAccountExplicitly(account, password, userbundle);
    } catch (SecurityException e) {
      // We use Log rather than Logger here to avoid possibly hiding these errors.
      final String message = e.getMessage();
      if (message != null && (message.indexOf("is different than the authenticator's uid") > 0)) {
        Log.wtf(SyncConstants.GLOBAL_LOG_TAG,
                "Unable to create account. " +
                "If you have more than one version of " +
                "Firefox/Beta/Aurora/Nightly/Fennec installed, that's why.",
                e);
      } else {
        Log.e(SyncConstants.GLOBAL_LOG_TAG, "Unable to create account.", e);
      }
    }

    if (!result) {
      Logger.error(LOG_TAG, "Failed to add account " + account + "!");
      return null;
    }
    Logger.debug(LOG_TAG, "Account " + account + " added successfully.");

    setSyncAutomatically(account, syncAutomatically);
    setIsSyncable(account, syncAutomatically);
    Logger.debug(LOG_TAG, "Set account to sync automatically? " + syncAutomatically + ".");

    try {
      final String product = GlobalConstants.BROWSER_INTENT_PACKAGE;
      final String profile = Constants.DEFAULT_PROFILE;
      final long version = SyncConfiguration.CURRENT_PREFS_VERSION;

      final SharedPreferences.Editor editor = Utils.getSharedPreferences(context, product, username, serverURL, profile, version).edit();
      if (clearPreferences) {
        final String prefsPath = Utils.getPrefsPath(product, username, serverURL, profile, version);
        Logger.info(LOG_TAG, "Clearing preferences path " + prefsPath + " for this account.");
        editor.clear();
      }

      if (syncAccount.clusterURL != null) {
        editor.putString(SyncConfiguration.PREF_CLUSTER_URL, syncAccount.clusterURL);
      }

      if (syncAccount.clientName != null && syncAccount.clientGuid != null) {
        Logger.debug(LOG_TAG, "Setting client name to " + syncAccount.clientName + " and client GUID to " + syncAccount.clientGuid + ".");
        editor.putString(SyncConfiguration.PREF_CLIENT_NAME, syncAccount.clientName);
        editor.putString(SyncConfiguration.PREF_ACCOUNT_GUID, syncAccount.clientGuid);
      } else {
        Logger.debug(LOG_TAG, "Client name and guid not both non-null, so not setting client data.");
      }

      editor.commit();
    } catch (Exception e) {
      Logger.error(LOG_TAG, "Could not clear prefs path!", e);
    }
    return account;
  }

  public static void setIsSyncable(Account account, boolean isSyncable) {
    String authority = BrowserContract.AUTHORITY;
    ContentResolver.setIsSyncable(account, authority, isSyncable ? 1 : 0);
  }

  public static void setSyncAutomatically(Account account, boolean syncAutomatically) {
    if (syncAutomatically) {
      ContentResolver.setMasterSyncAutomatically(true);
    }

    String authority = BrowserContract.AUTHORITY;
    Logger.debug(LOG_TAG, "Setting authority " + authority + " to " +
                          (syncAutomatically ? "" : "not ") + "sync automatically.");
    ContentResolver.setSyncAutomatically(account, authority, syncAutomatically);
  }

  public static void backgroundSetSyncAutomatically(final Account account, final boolean syncAutomatically) {
    ThreadPool.run(new Runnable() {
      @Override
      public void run() {
        setSyncAutomatically(account, syncAutomatically);
      }
    });
  }
  /**
   * Bug 721760: try to start a vendor-specific Accounts & Sync activity on Moto
   * Blur devices.
   * <p>
   * Bug 773562: actually start and catch <code>ActivityNotFoundException</code>,
   * rather than just returning the <code>Intent</code> only, because some
   * Moto devices fail to start the activity.
   *
   * @param context
   *          current Android context.
   * @param vendorPackage
   *          vendor specific package name.
   * @param vendorClass
   *          vendor specific class name.
   * @return null on failure, otherwise the <code>Intent</code> started.
   */
  protected static Intent openVendorSyncSettings(Context context, final String vendorPackage, final String vendorClass) {
    try {
      final int contextFlags = Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY;
      Context foreignContext = context.createPackageContext(vendorPackage, contextFlags);
      Class<?> klass = foreignContext.getClassLoader().loadClass(vendorClass);

      final Intent intent = new Intent(foreignContext, klass);
      context.startActivity(intent);
      Logger.info(LOG_TAG, "Vendor package " + vendorPackage + " and class " +
          vendorClass + " found, and activity launched.");
      return intent;
    } catch (NameNotFoundException e) {
      Logger.debug(LOG_TAG, "Vendor package " + vendorPackage + " not found. Skipping.");
    } catch (ClassNotFoundException e) {
      Logger.debug(LOG_TAG, "Vendor package " + vendorPackage + " found but class " +
          vendorClass + " not found. Skipping.", e);
    } catch (ActivityNotFoundException e) {
      // Bug 773562 - android.content.ActivityNotFoundException on Motorola devices.
      Logger.warn(LOG_TAG, "Vendor package " + vendorPackage + " and class " +
          vendorClass + " found, but activity not launched. Skipping.", e);
    } catch (Exception e) {
      // Just in case.
      Logger.warn(LOG_TAG, "Caught exception launching activity from vendor package " + vendorPackage +
          " and class " + vendorClass + ". Ignoring.", e);
    }
    return null;
  }

  /**
   * Start Sync settings activity.
   *
   * @param context
   *          current Android context.
   * @return the <code>Intent</code> started.
   */
  public static Intent openSyncSettings(Context context) {
    // Bug 721760 - opening Sync settings takes user to Battery & Data Manager
    // on a variety of Motorola devices. This work around tries to load the
    // correct Intent by hand. Oh, Android.
    Intent intent = openVendorSyncSettings(context, MOTO_BLUR_PACKAGE, MOTO_BLUR_SETTINGS_ACTIVITY);
    if (intent != null) {
      return intent;
    }

    // Open default Sync settings activity.
    intent = new Intent(Settings.ACTION_SYNC_SETTINGS);
    // Bug 774233: do not start activity as a new task (second run fails on some HTC devices).
    context.startActivity(intent); // We should always find this Activity.
    return intent;
  }

  /**
   * Synchronously extract Sync account parameters from Android account version
   * 0, using plain auth token type.
   * <p>
   * Safe to call from main thread.
   *
   * @param context
   *          Android context.
   * @param accountManager
   *          Android account manager.
   * @param account
   *          Android Account.
   * @return Sync account parameters, always non-null; fields username,
   *         password, serverURL, and syncKey always non-null.
   */
  public static SyncAccountParameters blockingFromAndroidAccountV0(final Context context, final AccountManager accountManager, final Account account)
      throws CredentialException {
    String username;
    try {
      username = Utils.usernameFromAccount(account.name);
    } catch (NoSuchAlgorithmException e) {
      throw new CredentialException.MissingCredentialException("username");
    } catch (UnsupportedEncodingException e) {
      throw new CredentialException.MissingCredentialException("username");
    }

    /*
     * If we are accessing an Account that we don't own, Android will throw an
     * unchecked <code>SecurityException</code> saying
     * "W FxSync(XXXX) java.lang.SecurityException: caller uid XXXXX is different than the authenticator's uid".
     * We catch that error and throw accordingly.
     */
    String password;
    String syncKey;
    String serverURL;
    try {
      password = accountManager.getPassword(account);
      syncKey = accountManager.getUserData(account, Constants.OPTION_SYNCKEY);
      serverURL = accountManager.getUserData(account, Constants.OPTION_SERVER);
    } catch (SecurityException e) {
      Logger.warn(LOG_TAG, "Got security exception fetching Sync account parameters; throwing.");
      throw new CredentialException.MissingAllCredentialsException(e);
    }

    if (password  == null &&
        username  == null &&
        syncKey   == null &&
        serverURL == null) {
      throw new CredentialException.MissingAllCredentialsException();
    }

    if (password == null) {
      throw new CredentialException.MissingCredentialException("password");
    }

    if (syncKey == null) {
      throw new CredentialException.MissingCredentialException("syncKey");
    }

    if (serverURL == null) {
      throw new CredentialException.MissingCredentialException("serverURL");
    }

    try {
      // SyncAccountParameters constructor throws on null inputs. This shouldn't
      // happen, but let's be safe.
      return new SyncAccountParameters(context, accountManager, username, syncKey, password, serverURL);
    } catch (Exception e) {
      Logger.warn(LOG_TAG, "Got exception fetching Sync account parameters; throwing.");
      throw new CredentialException.MissingAllCredentialsException(e);
    }
  }

  /**
   * Bug 790931: create an intent announcing that a Sync account will be
   * deleted.
   * <p>
   * This intent <b>must</b> be broadcast with secure permissions, because it
   * contains sensitive user information including the Sync account password and
   * Sync key.
   * <p>
   * Version 1 of the created intent includes extras with keys
   * <code>Constants.JSON_KEY_VERSION</code>,
   * <code>Constants.JSON_KEY_TIMESTAMP</code>, and
   * <code>Constants.JSON_KEY_ACCOUNT</code> (which is the Android Account name,
   * not the encoded Sync Account name).
   * <p>
   * If possible, it contains the key <code>Constants.JSON_KEY_PAYLOAD</code>
   * with value the Sync account parameters as JSON, <b>except the Sync key has
   * been replaced with the empty string</b>. (We replace, rather than remove,
   * the Sync key because SyncAccountParameters expects a non-null Sync key.)
   *
   * @see SyncAccountParameters#asJSON
   *
   * @param context
   *          Android context.
   * @param accountManager
   *          Android account manager.
   * @param account
   *          Android account being removed.
   * @return <code>Intent</code> to broadcast.
   */
  public static Intent makeSyncAccountDeletedIntent(final Context context, final AccountManager accountManager, final Account account) {
    final Intent intent = new Intent(SyncConstants.SYNC_ACCOUNT_DELETED_ACTION);

    intent.putExtra(Constants.JSON_KEY_VERSION, Long.valueOf(SyncConstants.SYNC_ACCOUNT_DELETED_INTENT_VERSION));
    intent.putExtra(Constants.JSON_KEY_TIMESTAMP, Long.valueOf(System.currentTimeMillis()));
    intent.putExtra(Constants.JSON_KEY_ACCOUNT, account.name);

    SyncAccountParameters accountParameters = null;
    try {
      accountParameters = SyncAccounts.blockingFromAndroidAccountV0(context, accountManager, account);
    } catch (Exception e) {
      Logger.warn(LOG_TAG, "Caught exception fetching account parameters.", e);
    }

    if (accountParameters != null) {
      ExtendedJSONObject json = accountParameters.asJSON();
      json.put(Constants.JSON_KEY_SYNCKEY, ""); // Reduce attack surface area by removing Sync key.
      intent.putExtra(Constants.JSON_KEY_PAYLOAD, json.toJSONString());
    }

    return intent;
  }

  /**
   * Synchronously fetch SharedPreferences of a profile associated with a Sync
   * account.
   * <p>
   * Safe to call from main thread.
   *
   * @param context
   *          Android context.
   * @param accountManager
   *          Android account manager.
   * @param account
   *          Android Account.
   * @param product
   *          package.
   * @param profile
   *          of account.
   * @param version
   *          number.
   * @return SharedPreferences associated with Sync account.
   * @throws CredentialException
   * @throws NoSuchAlgorithmException
   * @throws UnsupportedEncodingException
   */
  public static SharedPreferences blockingPrefsFromAndroidAccountV0(final Context context, final AccountManager accountManager, final Account account,
      final String product, final String profile, final long version)
          throws CredentialException, NoSuchAlgorithmException, UnsupportedEncodingException {
    SyncAccountParameters params = SyncAccounts.blockingFromAndroidAccountV0(context, accountManager, account);
    String prefsPath = Utils.getPrefsPath(product, params.username, params.serverURL, profile, version);

    return context.getSharedPreferences(prefsPath, Utils.SHARED_PREFERENCES_MODE);
  }

  /**
   * Synchronously fetch SharedPreferences of a profile associated with the
   * default Firefox profile of a Sync Account.
   * <p>
   * Uses the default package, default profile, and current version.
   * <p>
   * Safe to call from main thread.
   *
   * @param context
   *          Android context.
   * @param accountManager
   *          Android account manager.
   * @param account
   *          Android Account.
   * @return SharedPreferences associated with Sync account.
   * @throws CredentialException
   * @throws NoSuchAlgorithmException
   * @throws UnsupportedEncodingException
   */
  public static SharedPreferences blockingPrefsFromDefaultProfileV0(final Context context, final AccountManager accountManager, final Account account)
      throws CredentialException, NoSuchAlgorithmException, UnsupportedEncodingException {
    final String product = GlobalConstants.BROWSER_INTENT_PACKAGE;
    final String profile = Constants.DEFAULT_PROFILE;
    final long version = SyncConfiguration.CURRENT_PREFS_VERSION;

    return blockingPrefsFromAndroidAccountV0(context, accountManager, account, product, profile, version);
  }
}
