/*
 * Copyright (C) 2021 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 com.android.systemui.people.widget;

import static com.android.systemui.people.PeopleBackupFollowUpJob.SHARED_FOLLOW_UP;
import static com.android.systemui.people.PeopleSpaceUtils.DEBUG;
import static com.android.systemui.people.PeopleSpaceUtils.INVALID_USER_ID;
import static com.android.systemui.people.PeopleSpaceUtils.USER_ID;

import android.app.backup.BackupDataInputStream;
import android.app.backup.BackupDataOutput;
import android.app.backup.SharedPreferencesBackupHelper;
import android.app.people.IPeopleManager;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.ContentProvider;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.ServiceManager;
import android.os.UserHandle;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;

import androidx.annotation.Nullable;

import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.people.PeopleBackupFollowUpJob;
import com.android.systemui.people.SharedPreferencesHelper;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Helper class to backup and restore Conversations widgets storage.
 * It is used by SystemUI's BackupHelper agent.
 * TODO(b/192334798): Lock access to storage using PeopleSpaceWidgetManager's lock.
 */
public class PeopleBackupHelper extends SharedPreferencesBackupHelper {
    private static final String TAG = "PeopleBackupHelper";

    public static final String ADD_USER_ID_TO_URI = "add_user_id_to_uri_";
    public static final String SHARED_BACKUP = "shared_backup";

    private final Context mContext;
    private final UserHandle mUserHandle;
    private final PackageManager mPackageManager;
    private final IPeopleManager mIPeopleManager;
    @Nullable
    private final AppWidgetManager mAppWidgetManager;

    /**
     * Types of entries stored in the default SharedPreferences file for Conversation widgets.
     * Widget ID corresponds to a pair [widgetId, contactURI].
     * PeopleTileKey corresponds to a pair [PeopleTileKey, {widgetIds}].
     * Contact URI corresponds to a pair [Contact URI, {widgetIds}].
     */
    enum SharedFileEntryType {
        UNKNOWN,
        WIDGET_ID,
        PEOPLE_TILE_KEY,
        CONTACT_URI
    }

    /**
     * Returns the file names that should be backed up and restored by SharedPreferencesBackupHelper
     * infrastructure.
     */
    public static List<String> getFilesToBackup() {
        return Collections.singletonList(SHARED_BACKUP);
    }

    public PeopleBackupHelper(Context context, UserHandle userHandle,
            String[] sharedPreferencesKey) {
        super(context, sharedPreferencesKey);
        mContext = context;
        mUserHandle = userHandle;
        mPackageManager = context.getPackageManager();
        mIPeopleManager = IPeopleManager.Stub.asInterface(
                ServiceManager.getService(Context.PEOPLE_SERVICE));
        mAppWidgetManager = AppWidgetManager.getInstance(context);
    }

    @VisibleForTesting
    public PeopleBackupHelper(Context context, UserHandle userHandle,
            String[] sharedPreferencesKey, PackageManager packageManager,
            IPeopleManager peopleManager) {
        super(context, sharedPreferencesKey);
        mContext = context;
        mUserHandle = userHandle;
        mPackageManager = packageManager;
        mIPeopleManager = peopleManager;
        mAppWidgetManager = AppWidgetManager.getInstance(context);
    }

    /**
     * Reads values from default storage, backs them up appropriately to a specified backup file,
     * and calls super's performBackup, which backs up the values of the backup file.
     */
    @Override
    public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput data,
            ParcelFileDescriptor newState) {
        if (DEBUG) Log.d(TAG, "Backing up conversation widgets, writing to: " + SHARED_BACKUP);
        // Open default value for readings values.
        SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext);
        if (sp.getAll().isEmpty()) {
            if (DEBUG) Log.d(TAG, "No information to be backed up, finishing.");
            return;
        }

        // Open backup file for writing.
        SharedPreferences backupSp = mContext.getSharedPreferences(
                SHARED_BACKUP, Context.MODE_PRIVATE);
        SharedPreferences.Editor backupEditor = backupSp.edit();
        backupEditor.clear();

        // Fetch Conversations widgets corresponding to this user.
        List<String> existingWidgets = getExistingWidgetsForUser(mUserHandle.getIdentifier());
        if (existingWidgets.isEmpty()) {
            if (DEBUG) Log.d(TAG, "No existing Conversations widgets, returning.");
            return;
        }

        // Writes each entry to backup file.
        sp.getAll().entrySet().forEach(entry -> backupKey(entry, backupEditor, existingWidgets));
        backupEditor.apply();

        super.performBackup(oldState, data, newState);
    }

    /**
     * Restores backed up values to backup file via super's restoreEntity, then transfers them
     * back to regular storage. Restore operations for each users are done in sequence, so we can
     * safely use the same backup file names.
     */
    @Override
    public void restoreEntity(BackupDataInputStream data) {
        if (DEBUG) Log.d(TAG, "Restoring Conversation widgets.");
        super.restoreEntity(data);

        // Open backup file for reading values.
        SharedPreferences backupSp = mContext.getSharedPreferences(
                SHARED_BACKUP, Context.MODE_PRIVATE);

        // Open default file and follow-up file for writing.
        SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext);
        SharedPreferences.Editor editor = sp.edit();
        SharedPreferences followUp = mContext.getSharedPreferences(
                SHARED_FOLLOW_UP, Context.MODE_PRIVATE);
        SharedPreferences.Editor followUpEditor = followUp.edit();

        // Writes each entry back to default value.
        boolean shouldScheduleJob = false;
        for (Map.Entry<String, ?> entry : backupSp.getAll().entrySet()) {
            boolean restored = restoreKey(entry, editor, followUpEditor, backupSp);
            if (!restored) {
                shouldScheduleJob = true;
            }
        }

        editor.apply();
        followUpEditor.apply();
        SharedPreferencesHelper.clear(backupSp);

        // If any of the widgets is not yet available, schedule a follow-up job to check later.
        if (shouldScheduleJob) {
            if (DEBUG) Log.d(TAG, "At least one shortcut is not available, scheduling follow-up.");
            PeopleBackupFollowUpJob.scheduleJob(mContext);
        }

        updateWidgets(mContext);
    }

    /** Backs up an entry from default file to backup file. */
    public void backupKey(Map.Entry<String, ?> entry, SharedPreferences.Editor backupEditor,
            List<String> existingWidgets) {
        String key = entry.getKey();
        if (TextUtils.isEmpty(key)) {
            return;
        }

        SharedFileEntryType entryType = getEntryType(entry);
        switch(entryType) {
            case WIDGET_ID:
                backupWidgetIdKey(key, String.valueOf(entry.getValue()), backupEditor,
                        existingWidgets);
                break;
            case PEOPLE_TILE_KEY:
                backupPeopleTileKey(key, (Set<String>) entry.getValue(), backupEditor,
                        existingWidgets);
                break;
            case CONTACT_URI:
                backupContactUriKey(key, (Set<String>) entry.getValue(), backupEditor);
                break;
            case UNKNOWN:
            default:
                Log.w(TAG, "Key not identified, skipping: " + key);
        }
    }

    /**
     * Tries to restore an entry from backup file to default file.
     * Returns true if restore is finished, false if it needs to be checked later.
     */
    boolean restoreKey(Map.Entry<String, ?> entry, SharedPreferences.Editor editor,
            SharedPreferences.Editor followUpEditor, SharedPreferences backupSp) {
        String key = entry.getKey();
        SharedFileEntryType keyType = getEntryType(entry);
        int storedUserId = backupSp.getInt(ADD_USER_ID_TO_URI + key, INVALID_USER_ID);
        switch (keyType) {
            case WIDGET_ID:
                restoreWidgetIdKey(key, String.valueOf(entry.getValue()), editor, storedUserId);
                return true;
            case PEOPLE_TILE_KEY:
                return restorePeopleTileKeyAndCorrespondingWidgetFile(
                        key, (Set<String>) entry.getValue(), editor, followUpEditor);
            case CONTACT_URI:
                restoreContactUriKey(key, (Set<String>) entry.getValue(), editor, storedUserId);
                return true;
            case UNKNOWN:
            default:
                Log.e(TAG, "Key not identified, skipping:" + key);
                return true;
        }
    }

    /**
     * Backs up a [widgetId, contactURI] pair, if widget id corresponds to current user.
     * If contact URI has a user id, stores it so it can be re-added on restore.
     */
    private void backupWidgetIdKey(String key, String uriString, SharedPreferences.Editor editor,
            List<String> existingWidgets) {
        if (!existingWidgets.contains(key)) {
            if (DEBUG) Log.d(TAG, "Widget: " + key + " does't correspond to this user, skipping.");
            return;
        }
        Uri uri = Uri.parse(uriString);
        if (ContentProvider.uriHasUserId(uri)) {
            if (DEBUG) Log.d(TAG, "Contact URI value has user ID, removing from: " + uri);
            int userId = ContentProvider.getUserIdFromUri(uri);
            editor.putInt(ADD_USER_ID_TO_URI + key, userId);
            uri = ContentProvider.getUriWithoutUserId(uri);
        }
        if (DEBUG) Log.d(TAG, "Backing up widgetId key: " + key + " . Value: " + uri.toString());
        editor.putString(key, uri.toString());
    }

    /** Restores a [widgetId, contactURI] pair, and a potential {@code storedUserId}. */
    private void restoreWidgetIdKey(String key, String uriString, SharedPreferences.Editor editor,
            int storedUserId) {
        Uri uri = Uri.parse(uriString);
        if (storedUserId != INVALID_USER_ID) {
            uri = ContentProvider.createContentUriForUser(uri, UserHandle.of(storedUserId));
            if (DEBUG) Log.d(TAG, "UserId was removed from URI on back up, re-adding as:" + uri);

        }
        if (DEBUG) Log.d(TAG, "Restoring widgetId key: " + key + " . Value: " + uri.toString());
        editor.putString(key, uri.toString());
    }

    /**
     * Backs up a [PeopleTileKey, {widgetIds}] pair, if PeopleTileKey's user is the same as current
     * user, stripping out the user id.
     */
    private void backupPeopleTileKey(String key, Set<String> widgetIds,
            SharedPreferences.Editor editor, List<String> existingWidgets) {
        PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key);
        if (peopleTileKey.getUserId() != mUserHandle.getIdentifier()) {
            if (DEBUG) Log.d(TAG, "PeopleTileKey corresponds to different user, skipping backup.");
            return;
        }

        Set<String> filteredWidgets = widgetIds.stream()
                .filter(id -> existingWidgets.contains(id))
                .collect(Collectors.toSet());
        if (filteredWidgets.isEmpty()) {
            return;
        }

        peopleTileKey.setUserId(INVALID_USER_ID);
        if (DEBUG) {
            Log.d(TAG, "Backing up PeopleTileKey key: " + peopleTileKey.toString() + ". Value: "
                    + filteredWidgets);
        }
        editor.putStringSet(peopleTileKey.toString(), filteredWidgets);
    }

    /**
     * Restores a [PeopleTileKey, {widgetIds}] pair, restoring the user id. Checks if the
     * corresponding shortcut exists, and if not, we should schedule a follow up to check later.
     * Also restores corresponding [widgetId, PeopleTileKey], which is not backed up since the
     * information can be inferred from this.
     * Returns true if restore is finished, false if we should check if shortcut is available later.
     */
    private boolean restorePeopleTileKeyAndCorrespondingWidgetFile(String key,
            Set<String> widgetIds, SharedPreferences.Editor editor,
            SharedPreferences.Editor followUpEditor) {
        PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key);
        // Should never happen, as type of key has been checked.
        if (peopleTileKey == null) {
            if (DEBUG) Log.d(TAG, "PeopleTileKey key to be restored is null, skipping.");
            return true;
        }

        peopleTileKey.setUserId(mUserHandle.getIdentifier());
        if (!PeopleTileKey.isValid(peopleTileKey)) {
            if (DEBUG) Log.d(TAG, "PeopleTileKey key to be restored is not valid, skipping.");
            return true;
        }

        boolean restored = isReadyForRestore(
                mIPeopleManager, mPackageManager, peopleTileKey);
        if (!restored) {
            if (DEBUG) Log.d(TAG, "Adding key to follow-up storage: " + peopleTileKey.toString());
            // Follow-up file stores shortcuts that need to be checked later, and possibly wiped
            // from our storage.
            followUpEditor.putStringSet(peopleTileKey.toString(), widgetIds);
        }

        if (DEBUG) {
            Log.d(TAG, "Restoring PeopleTileKey key: " + peopleTileKey.toString() + " . Value: "
                    + widgetIds);
        }
        editor.putStringSet(peopleTileKey.toString(), widgetIds);
        restoreWidgetIdFiles(mContext, widgetIds, peopleTileKey);
        return restored;
    }

    /**
     * Backs up a [contactURI, {widgetIds}] pair. If contactURI contains a userId, we back up
     * this entry in the corresponding user. If it doesn't, we back it up as user 0.
     * If contact URI has a user id, stores it so it can be re-added on restore.
     * We do not take existing widgets for this user into consideration.
     */
    private void backupContactUriKey(String key, Set<String> widgetIds,
            SharedPreferences.Editor editor) {
        Uri uri = Uri.parse(String.valueOf(key));
        if (ContentProvider.uriHasUserId(uri)) {
            int userId = ContentProvider.getUserIdFromUri(uri);
            if (DEBUG) Log.d(TAG, "Contact URI has user Id: " + userId);
            if (userId == mUserHandle.getIdentifier()) {
                uri = ContentProvider.getUriWithoutUserId(uri);
                if (DEBUG) {
                    Log.d(TAG, "Backing up contactURI key: " + uri.toString() + " . Value: "
                            + widgetIds);
                }
                editor.putInt(ADD_USER_ID_TO_URI + uri.toString(), userId);
                editor.putStringSet(uri.toString(), widgetIds);
            } else {
                if (DEBUG) Log.d(TAG, "ContactURI corresponds to different user, skipping.");
            }
        } else if (mUserHandle.isSystem()) {
            if (DEBUG) {
                Log.d(TAG, "Backing up contactURI key: " + uri.toString() + " . Value: "
                        + widgetIds);
            }
            editor.putStringSet(uri.toString(), widgetIds);
        }
    }

    /** Restores a [contactURI, {widgetIds}] pair, and a potential {@code storedUserId}. */
    private void restoreContactUriKey(String key, Set<String> widgetIds,
            SharedPreferences.Editor editor, int storedUserId) {
        Uri uri = Uri.parse(key);
        if (storedUserId != INVALID_USER_ID) {
            uri = ContentProvider.createContentUriForUser(uri, UserHandle.of(storedUserId));
            if (DEBUG) Log.d(TAG, "UserId was removed from URI on back up, re-adding as:" + uri);
        }
        if (DEBUG) {
            Log.d(TAG, "Restoring contactURI key: " + uri.toString() + " . Value: " + widgetIds);
        }
        editor.putStringSet(uri.toString(), widgetIds);
    }

    /** Restores the widget-specific files that contain PeopleTileKey information. */
    public static void restoreWidgetIdFiles(Context context, Set<String> widgetIds,
            PeopleTileKey key) {
        for (String id : widgetIds) {
            if (DEBUG) Log.d(TAG, "Restoring widget Id file: " + id + " . Value: " + key);
            SharedPreferences dest = context.getSharedPreferences(id, Context.MODE_PRIVATE);
            SharedPreferencesHelper.setPeopleTileKey(dest, key);
        }
    }

    private List<String> getExistingWidgetsForUser(int userId) {
        List<String> existingWidgets = new ArrayList<>();
        if (mAppWidgetManager == null) {
            return existingWidgets;
        }
        int[] ids = mAppWidgetManager.getAppWidgetIds(
                new ComponentName(mContext, PeopleSpaceWidgetProvider.class));
        for (int id : ids) {
            String idString = String.valueOf(id);
            SharedPreferences sp = mContext.getSharedPreferences(idString, Context.MODE_PRIVATE);
            if (sp.getInt(USER_ID, INVALID_USER_ID) == userId) {
                existingWidgets.add(idString);
            }
        }
        if (DEBUG) Log.d(TAG, "Existing widgets: " + existingWidgets);
        return existingWidgets;
    }

    /**
     * Returns whether {@code key} corresponds to a shortcut that is ready for restore, either
     * because it is available or because it never will be. If not ready, we schedule a job to check
     * again later.
     */
    public static boolean isReadyForRestore(IPeopleManager peopleManager,
            PackageManager packageManager, PeopleTileKey key) {
        if (DEBUG) Log.d(TAG, "Checking if we should schedule a follow up job : " + key);
        if (!PeopleTileKey.isValid(key)) {
            if (DEBUG) Log.d(TAG, "Key is invalid, should not follow up.");
            return true;
        }

        try {
            PackageInfo info = packageManager.getPackageInfoAsUser(
                    key.getPackageName(), 0, key.getUserId());
        } catch (PackageManager.NameNotFoundException e) {
            if (DEBUG) Log.d(TAG, "Package is not installed, should follow up.");
            return false;
        }

        try {
            boolean isConversation = peopleManager.isConversation(
                    key.getPackageName(), key.getUserId(), key.getShortcutId());
            if (DEBUG) {
                Log.d(TAG, "Checked if shortcut exists, should follow up: " + !isConversation);
            }
            return isConversation;
        } catch (Exception e) {
            if (DEBUG) Log.d(TAG, "Error checking if backed up info is a shortcut.");
            return false;
        }
    }

    /** Parses default file {@code entry} to determine the entry's type.*/
    public static SharedFileEntryType getEntryType(Map.Entry<String, ?> entry) {
        String key = entry.getKey();
        if (key == null) {
            return SharedFileEntryType.UNKNOWN;
        }

        try {
            int id = Integer.parseInt(key);
            try {
                String contactUri = (String) entry.getValue();
            } catch (Exception e) {
                Log.w(TAG, "Malformed value, skipping:" + entry.getValue());
                return SharedFileEntryType.UNKNOWN;
            }
            return SharedFileEntryType.WIDGET_ID;
        } catch (NumberFormatException ignored) { }

        try {
            Set<String> widgetIds = (Set<String>) entry.getValue();
        } catch (Exception e) {
            Log.w(TAG, "Malformed value, skipping:" + entry.getValue());
            return SharedFileEntryType.UNKNOWN;
        }

        PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key);
        if (peopleTileKey != null) {
            return SharedFileEntryType.PEOPLE_TILE_KEY;
        }

        try {
            Uri uri = Uri.parse(key);
            return SharedFileEntryType.CONTACT_URI;
        } catch (Exception e) {
            return SharedFileEntryType.UNKNOWN;
        }
    }

    /** Sends a broadcast to update the existing Conversation widgets. */
    public static void updateWidgets(Context context) {
        AppWidgetManager manager = AppWidgetManager.getInstance(context);
        if (manager == null) {
            return;
        }
        int[] widgetIds = manager
                .getAppWidgetIds(new ComponentName(context, PeopleSpaceWidgetProvider.class));
        if (DEBUG) {
            for (int id : widgetIds) {
                Log.d(TAG, "Calling update to widget: " + id);
            }
        }
        if (widgetIds != null && widgetIds.length != 0) {
            Intent intent = new Intent(context, PeopleSpaceWidgetProvider.class);
            intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
            intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds);
            context.sendBroadcast(intent);
        }
    }
}
