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

package org.chromium.components.sync;

import android.accounts.Account;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SyncStatusObserver;
import android.os.Bundle;
import android.os.StrictMode;

import org.chromium.base.Callback;
import org.chromium.base.ObserverList;
import org.chromium.base.VisibleForTesting;
import org.chromium.components.signin.AccountManagerHelper;
import org.chromium.components.signin.ChromeSigninController;

import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;

/**
 * A helper class to handle the current status of sync for Chrome in Android settings.
 *
 * It also provides an observer to be used whenever Android sync settings change.
 *
 * This class is a collection of static methods so that no references to its object can be
 * stored. This is important because tests need to be able to overwrite the object with a
 * mock content resolver and know that no references to the old one are cached.
 *
 * This class must be initialized via updateAccount() on startup if the user is signed in.
 */
@ThreadSafe
public class AndroidSyncSettings {
    public static final String TAG = "AndroidSyncSettings";

    /**
     * Lock for ensuring singleton instantiation across threads.
     */
    private static final Object CLASS_LOCK = new Object();

    @SuppressLint("StaticFieldLeak")
    private static AndroidSyncSettings sInstance;

    private final Object mLock = new Object();

    private final String mContractAuthority;

    private final Context mApplicationContext;

    private final SyncContentResolverDelegate mSyncContentResolverDelegate;

    private Account mAccount;

    private boolean mIsSyncable;

    private boolean mChromeSyncEnabled;

    private boolean mMasterSyncEnabled;

    private final ObserverList<AndroidSyncSettingsObserver> mObservers =
            new ObserverList<AndroidSyncSettingsObserver>();

    /**
     * Provides notifications when Android sync settings have changed.
     */
    public interface AndroidSyncSettingsObserver { public void androidSyncSettingsChanged(); }

    private static void ensureInitialized(Context context) {
        synchronized (CLASS_LOCK) {
            if (sInstance == null) {
                SyncContentResolverDelegate contentResolver =
                        new SystemSyncContentResolverDelegate();
                sInstance = new AndroidSyncSettings(context, contentResolver);
            }
        }
    }

    @VisibleForTesting
    public static void overrideForTests(
            Context context, SyncContentResolverDelegate contentResolver) {
        synchronized (CLASS_LOCK) {
            sInstance = new AndroidSyncSettings(context, contentResolver);
        }
    }

    /**
     * @param context the context the ApplicationContext will be retrieved from.
     * @param syncContentResolverDelegate an implementation of {@link SyncContentResolverDelegate}.
     */
    private AndroidSyncSettings(
            Context context, SyncContentResolverDelegate syncContentResolverDelegate) {
        mApplicationContext = context.getApplicationContext();
        mContractAuthority = mApplicationContext.getPackageName();
        mSyncContentResolverDelegate = syncContentResolverDelegate;

        mAccount = ChromeSigninController.get().getSignedInUser();
        updateSyncability(null);
        updateCachedSettings();

        mSyncContentResolverDelegate.addStatusChangeListener(
                ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS,
                new AndroidSyncSettingsChangedObserver());
    }

    /**
     * Checks whether sync is currently enabled from Chrome for the currently signed in account.
     *
     * It checks both the master sync for the device, and Chrome sync setting for the given account.
     * If no user is currently signed in it returns false.
     *
     * @return true if sync is on, false otherwise
     */
    public static boolean isSyncEnabled(Context context) {
        ensureInitialized(context);
        return sInstance.mMasterSyncEnabled && sInstance.mChromeSyncEnabled;
    }

    /**
     * Checks whether sync is currently enabled from Chrome for a given account.
     *
     * It checks only Chrome sync setting for the given account,
     * and ignores the master sync setting.
     *
     * @return true if sync is on, false otherwise
     */
    @VisibleForTesting
    public static boolean isChromeSyncEnabled(Context context) {
        ensureInitialized(context);
        return sInstance.mChromeSyncEnabled;
    }

    /**
     * Checks whether the master sync flag for Android is currently enabled.
     */
    public static boolean isMasterSyncEnabled(Context context) {
        ensureInitialized(context);
        return sInstance.mMasterSyncEnabled;
    }

    /**
     * Make sure Chrome is syncable, and enable sync.
     */
    public static void enableChromeSync(Context context) {
        ensureInitialized(context);
        sInstance.setChromeSyncEnabled(true);
    }

    /**
     * Disables Android Chrome sync
     */
    public static void disableChromeSync(Context context) {
        ensureInitialized(context);
        sInstance.setChromeSyncEnabled(false);
    }

    /**
     * Must be called when a new account is signed in.
     */
    public static void updateAccount(Context context, Account account) {
        updateAccount(context, account, null);
    }

    /**
     * Must be called when a new account is signed in.
     * @param callback Callback that will be called after updating account is finished. Boolean
     *                 passed to the callback indicates whether syncability was changed.
     */
    @VisibleForTesting
    public static void updateAccount(
            Context context, Account account, @Nullable Callback<Boolean> callback) {
        ensureInitialized(context);
        synchronized (sInstance.mLock) {
            sInstance.mAccount = account;
            sInstance.updateSyncability(callback);
        }
        if (sInstance.updateCachedSettings()) {
            sInstance.notifyObservers();
        }
    }

    /**
     * Returns the contract authority to use when requesting sync.
     */
    public static String getContractAuthority(Context context) {
        ensureInitialized(context);
        return sInstance.mContractAuthority;
    }

    /**
     * Add a new AndroidSyncSettingsObserver.
     */
    public static void registerObserver(Context context, AndroidSyncSettingsObserver observer) {
        ensureInitialized(context);
        synchronized (sInstance.mLock) {
            sInstance.mObservers.addObserver(observer);
        }
    }

    /**
     * Remove an AndroidSyncSettingsObserver that was previously added.
     */
    public static void unregisterObserver(Context context, AndroidSyncSettingsObserver observer) {
        ensureInitialized(context);
        synchronized (sInstance.mLock) {
            sInstance.mObservers.removeObserver(observer);
        }
    }

    private void setChromeSyncEnabled(boolean value) {
        synchronized (mLock) {
            updateSyncability(null);
            if (value == mChromeSyncEnabled || mAccount == null) return;
            mChromeSyncEnabled = value;

            StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
            mSyncContentResolverDelegate.setSyncAutomatically(mAccount, mContractAuthority, value);
            StrictMode.setThreadPolicy(oldPolicy);
        }
        notifyObservers();
    }

    /**
     * Ensure Chrome is registered with the Android Sync Manager iff signed in.
     *
     * This is what causes the "Chrome" option to appear in Settings -> Accounts -> Sync .
     * This function must be called within a synchronized block.
     */
    private void updateSyncability(@Nullable final Callback<Boolean> callback) {
        boolean shouldBeSyncable = mAccount != null;
        if (mIsSyncable == shouldBeSyncable) {
            if (callback != null) callback.onResult(false);
            return;
        }

        mIsSyncable = shouldBeSyncable;

        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
        // Make account syncable if there is one.
        if (shouldBeSyncable) {
            mSyncContentResolverDelegate.setIsSyncable(mAccount, mContractAuthority, 1);
            // This reduces unnecessary resource usage. See http://crbug.com/480688 for details.
            mSyncContentResolverDelegate.removePeriodicSync(
                    mAccount, mContractAuthority, Bundle.EMPTY);
        }
        StrictMode.setThreadPolicy(oldPolicy);

        // Disable the syncability of Chrome for all other accounts.
        AccountManagerHelper.get().getGoogleAccounts(new Callback<Account[]>() {
            @Override
            public void onResult(Account[] accounts) {
                synchronized (mLock) {
                    StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
                    for (Account account : accounts) {
                        if (!account.equals(mAccount)
                                && mSyncContentResolverDelegate.getIsSyncable(
                                           account, mContractAuthority)
                                        > 0) {
                            mSyncContentResolverDelegate.setIsSyncable(
                                    account, mContractAuthority, 0);
                        }
                    }
                    StrictMode.setThreadPolicy(oldPolicy);
                }

                if (callback != null) callback.onResult(true);
            }
        });
    }

    /**
     * Helper class to be used by observers whenever sync settings change.
     *
     * To register the observer, call AndroidSyncSettings.registerObserver(...).
     */
    private class AndroidSyncSettingsChangedObserver implements SyncStatusObserver {
        @Override
        public void onStatusChanged(int which) {
            if (which == ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS) {
                // Sync settings have changed; update our cached values.
                if (updateCachedSettings()) {
                    // If something actually changed, tell our observers.
                    notifyObservers();
                }
            }
        }
    }

    /**
     * Update the three cached settings from the content resolver.
     *
     * @return Whether either chromeSyncEnabled or masterSyncEnabled changed.
     */
    private boolean updateCachedSettings() {
        synchronized (mLock) {
            boolean oldChromeSyncEnabled = mChromeSyncEnabled;
            boolean oldMasterSyncEnabled = mMasterSyncEnabled;

            StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
            if (mAccount != null) {
                mIsSyncable =
                        mSyncContentResolverDelegate.getIsSyncable(mAccount, mContractAuthority)
                        == 1;
                mChromeSyncEnabled = mSyncContentResolverDelegate.getSyncAutomatically(
                        mAccount, mContractAuthority);
            } else {
                mIsSyncable = false;
                mChromeSyncEnabled = false;
            }
            mMasterSyncEnabled = mSyncContentResolverDelegate.getMasterSyncAutomatically();
            StrictMode.setThreadPolicy(oldPolicy);

            return oldChromeSyncEnabled != mChromeSyncEnabled
                    || oldMasterSyncEnabled != mMasterSyncEnabled;
        }
    }

    private void notifyObservers() {
        for (AndroidSyncSettingsObserver observer : mObservers) {
            observer.androidSyncSettingsChanged();
        }
    }
}