/*
 * Copyright (C) 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.app.sdksandbox;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Syncs specified keys in default {@link SharedPreferences} to Sandbox.
 *
 * <p>This class is a singleton since we want to maintain sync between app process and sandbox
 * process.
 *
 * @hide
 */
public class SharedPreferencesSyncManager {

    private static final String TAG = "SdkSandboxSyncManager";
    private static ArrayMap<String, SharedPreferencesSyncManager> sInstanceMap = new ArrayMap<>();
    private final ISdkSandboxManager mService;
    private final Context mContext;
    private final Object mLock = new Object();
    private final ISharedPreferencesSyncCallback mCallback = new SharedPreferencesSyncCallback();

    @GuardedBy("mLock")
    private boolean mWaitingForSandbox = false;

    // Set to a listener after initial bulk sync is successful
    @GuardedBy("mLock")
    private ChangeListener mListener = null;

    // Set of keys that this manager needs to keep in sync.
    @GuardedBy("mLock")
    private ArraySet<String> mKeysToSync = new ArraySet<>();

    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
    public SharedPreferencesSyncManager(
            @NonNull Context context, @NonNull ISdkSandboxManager service) {
        mContext = context.getApplicationContext();
        mService = service;
    }

    /**
     * Returns a new instance of this class if there is a new package, otherewise returns a
     * singleton instance.
     */
    public static synchronized SharedPreferencesSyncManager getInstance(
            @NonNull Context context, @NonNull ISdkSandboxManager service) {
        final String packageName = context.getPackageName();
        if (!sInstanceMap.containsKey(packageName)) {
            sInstanceMap.put(packageName, new SharedPreferencesSyncManager(context, service));
        }
        return sInstanceMap.get(packageName);
    }

    /**
     * Adds keys for syncing from app's default {@link SharedPreferences} to SdkSandbox.
     *
     * @see SdkSandboxManager#addSyncedSharedPreferencesKeys(Set)
     */
    public void addSharedPreferencesSyncKeys(@NonNull Set<String> keyNames) {
        // TODO(b/239403323): Validate the parameters in SdkSandboxManager
        synchronized (mLock) {
            mKeysToSync.addAll(keyNames);

            if (mListener == null) {
                mListener = new ChangeListener();
                getDefaultSharedPreferences().registerOnSharedPreferenceChangeListener(mListener);
            }
            syncData();
        }
    }

    /**
     * Removes keys from set of keys that have been added using {@link
     * #addSharedPreferencesSyncKeys(Set)}
     *
     * @see SdkSandboxManager#removeSyncedSharedPreferencesKeys(Set)
     */
    public void removeSharedPreferencesSyncKeys(@NonNull Set<String> keys) {
        synchronized (mLock) {
            mKeysToSync.removeAll(keys);

            final ArrayList<SharedPreferencesKey> keysWithTypeBeingRemoved = new ArrayList<>();

            for (final String key : keys) {
                keysWithTypeBeingRemoved.add(
                        new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING));
            }
            final SharedPreferencesUpdate update =
                    new SharedPreferencesUpdate(keysWithTypeBeingRemoved, new Bundle());
            try {
                SandboxLatencyInfo sandboxLatencyInfo =
                        new SandboxLatencyInfo(SandboxLatencyInfo.METHOD_SYNC_DATA_FROM_CLIENT);
                sandboxLatencyInfo.setTimeAppCalledSystemServer(SystemClock.elapsedRealtime());
                mService.syncDataFromClient(
                        mContext.getPackageName(), sandboxLatencyInfo, update, mCallback);
            } catch (RemoteException e) {
                Log.e(TAG, "Couldn't connect to SdkSandboxManagerService: " + e.getMessage());
            }
        }
    }

    /**
     * Returns the set of all keys that are being synced from app's default {@link
     * SharedPreferences} to sandbox.
     */
    public Set<String> getSharedPreferencesSyncKeys() {
        synchronized (mLock) {
            return new ArraySet(mKeysToSync);
        }
    }

    /**
     * Returns true if sync is in waiting state.
     *
     * <p>Sync transitions into waiting state whenever sdksandbox is unavailable. It resumes syncing
     * again when SdkSandboxManager notifies us that sdksandbox is available again.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
    public boolean isWaitingForSandbox() {
        synchronized (mLock) {
            return mWaitingForSandbox;
        }
    }

    /**
     * Syncs data to SdkSandbox.
     *
     * <p>Syncs values of specified keys {@link #mKeysToSync} from the default {@link
     * SharedPreferences} of the app.
     *
     * <p>Once bulk sync is complete, it also registers listener for updates which maintains the
     * sync.
     */
    private void syncData() {
        synchronized (mLock) {
            // Do not sync if keys have not been specified by the client.
            if (mKeysToSync.isEmpty()) {
                return;
            }

            bulkSyncData();
        }
    }

    @GuardedBy("mLock")
    private void bulkSyncData() {
        // Collect data in a bundle
        final Bundle data = new Bundle();
        final SharedPreferences pref = getDefaultSharedPreferences();
        final Map<String, ?> allData = pref.getAll();
        final ArrayList<SharedPreferencesKey> keysWithTypeBeingSynced = new ArrayList<>();

        for (int i = 0; i < mKeysToSync.size(); i++) {
            final String key = mKeysToSync.valueAt(i);
            final Object value = allData.get(key);
            if (value == null) {
                // Keep the key missing from the bundle; that means key has been removed.
                // Type of missing key doesn't matter, so we use a random type.
                keysWithTypeBeingSynced.add(
                        new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING));
                continue;
            }
            final SharedPreferencesKey keyWithTypeAdded = updateBundle(data, key, value);
            keysWithTypeBeingSynced.add(keyWithTypeAdded);
        }

        final SharedPreferencesUpdate update =
                new SharedPreferencesUpdate(keysWithTypeBeingSynced, data);
        try {
            SandboxLatencyInfo sandboxLatencyInfo =
                    new SandboxLatencyInfo(SandboxLatencyInfo.METHOD_SYNC_DATA_FROM_CLIENT);
            sandboxLatencyInfo.setTimeAppCalledSystemServer(SystemClock.elapsedRealtime());
            mService.syncDataFromClient(
                    mContext.getPackageName(), sandboxLatencyInfo, update, mCallback);
        } catch (RemoteException e) {
            Log.e(TAG, "Couldn't connect to SdkSandboxManagerService: " + e.getMessage());
        }
    }

    private SharedPreferences getDefaultSharedPreferences() {
        final Context appContext = mContext.getApplicationContext();
        return PreferenceManager.getDefaultSharedPreferences(appContext);
    }

    private class ChangeListener implements SharedPreferences.OnSharedPreferenceChangeListener {
        @Override
        public void onSharedPreferenceChanged(SharedPreferences pref, @Nullable String key) {
            // Sync specified keys only
            synchronized (mLock) {
                // Do not sync if we are in waiting state
                if (mWaitingForSandbox) {
                    return;
                }

                if (key == null) {
                    // All keys have been cleared. Bulk sync so that we send null for every key.
                    bulkSyncData();
                    return;
                }

                if (!mKeysToSync.contains(key)) {
                    return;
                }

                final Bundle data = new Bundle();
                SharedPreferencesKey keyWithType;
                final Object value = pref.getAll().get(key);
                if (value != null) {
                    keyWithType = updateBundle(data, key, value);
                } else {
                    keyWithType =
                            new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING);
                }

                final SharedPreferencesUpdate update =
                        new SharedPreferencesUpdate(List.of(keyWithType), data);
                try {
                    SandboxLatencyInfo sandboxLatencyInfo =
                            new SandboxLatencyInfo(SandboxLatencyInfo.METHOD_SYNC_DATA_FROM_CLIENT);
                    sandboxLatencyInfo.setTimeAppCalledSystemServer(SystemClock.elapsedRealtime());
                    mService.syncDataFromClient(
                            mContext.getPackageName(), sandboxLatencyInfo, update, mCallback);
                } catch (RemoteException e) {
                    Log.e(TAG, "Couldn't connect to SdkSandboxManagerService: " + e.getMessage());
                }
            }
        }
    }

    /**
     * Adds key to bundle based on type of value
     *
     * @return SharedPreferenceKey of the key that has been added
     */
    @GuardedBy("mLock")
    private SharedPreferencesKey updateBundle(Bundle data, String key, Object value) {
        final String type = value.getClass().getSimpleName();
        try {
            switch (type) {
                case "String":
                    data.putString(key, value.toString());
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING);
                case "Boolean":
                    data.putBoolean(key, (Boolean) value);
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_BOOLEAN);
                case "Integer":
                    data.putInt(key, (Integer) value);
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_INTEGER);
                case "Float":
                    data.putFloat(key, (Float) value);
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_FLOAT);
                case "Long":
                    data.putLong(key, (Long) value);
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_LONG);
                case "HashSet":
                    // TODO(b/239403323): Verify the set contains string
                    data.putStringArrayList(key, new ArrayList<>((Set<String>) value));
                    return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING_SET);
                default:
                    Log.e(
                            TAG,
                            "Unknown type found in default SharedPreferences for Key: "
                                    + key
                                    + " type: "
                                    + type);
            }
        } catch (ClassCastException ignore) {
            data.remove(key);
            Log.e(
                    TAG,
                    "Wrong type found in default SharedPreferences for Key: "
                            + key
                            + " Type: "
                            + type);
        }
        // By default, assume it's string
        return new SharedPreferencesKey(key, SharedPreferencesKey.KEY_TYPE_STRING);
    }

    private class SharedPreferencesSyncCallback extends ISharedPreferencesSyncCallback.Stub {
        @Override
        public void onSandboxStart() {
            synchronized (mLock) {
                if (mWaitingForSandbox) {
                    // Retry bulk sync if we were waiting for sandbox to start
                    mWaitingForSandbox = false;
                    bulkSyncData();
                }
            }
        }

        @Override
        public void onError(int errorCode, String errorMsg) {
            synchronized (mLock) {
                // Transition to waiting state when sandbox is unavailable
                if (!mWaitingForSandbox
                        && errorCode == ISharedPreferencesSyncCallback.SANDBOX_NOT_AVAILABLE) {
                    Log.w(TAG, "Waiting for SdkSandbox: " + errorMsg);
                    // Wait for sandbox to start. When it starts, server will call onSandboxStart
                    mWaitingForSandbox = true;
                    return;
                }
                Log.e(TAG, "errorCode: " + errorCode + " errorMsg: " + errorMsg);
            }
        }
    }
}
