/*
 * Copyright (C) 2014 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.server.backup;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.app.backup.BackupDataInputStream;
import android.app.backup.BackupDataOutput;
import android.app.backup.BackupHelper;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SyncAdapterType;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;

/**
 * Helper for backing up account sync settings (whether or not a service should be synced). The
 * sync settings are backed up as a JSON object containing all the necessary information for
 * restoring the sync settings later.
 */
public class AccountSyncSettingsBackupHelper implements BackupHelper {

    private static final String TAG = "AccountSyncSettingsBackupHelper";
    private static final boolean DEBUG = false;

    private static final int STATE_VERSION = 1;
    private static final int MD5_BYTE_SIZE = 16;
    private static final int SYNC_REQUEST_LATCH_TIMEOUT_SECONDS = 1;

    private static final String JSON_FORMAT_HEADER_KEY = "account_data";
    private static final String JSON_FORMAT_ENCODING = "UTF-8";
    private static final int JSON_FORMAT_VERSION = 1;

    private static final String KEY_VERSION = "version";
    private static final String KEY_MASTER_SYNC_ENABLED = "masterSyncEnabled";
    private static final String KEY_ACCOUNTS = "accounts";
    private static final String KEY_ACCOUNT_NAME = "name";
    private static final String KEY_ACCOUNT_TYPE = "type";
    private static final String KEY_ACCOUNT_AUTHORITIES = "authorities";
    private static final String KEY_AUTHORITY_NAME = "name";
    private static final String KEY_AUTHORITY_SYNC_STATE = "syncState";
    private static final String KEY_AUTHORITY_SYNC_ENABLED = "syncEnabled";
    private static final String STASH_FILE = Environment.getDataDirectory()
            + "/backup/unadded_account_syncsettings.json";

    private Context mContext;
    private AccountManager mAccountManager;

    public AccountSyncSettingsBackupHelper(Context context) {
        mContext = context;
        mAccountManager = AccountManager.get(mContext);
    }

    /**
     * Take a snapshot of the current account sync settings and write them to the given output.
     */
    @Override
    public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput output,
            ParcelFileDescriptor newState) {
        try {
            JSONObject dataJSON = serializeAccountSyncSettingsToJSON();

            if (DEBUG) {
                Log.d(TAG, "Account sync settings JSON: " + dataJSON);
            }

            // Encode JSON data to bytes.
            byte[] dataBytes = dataJSON.toString().getBytes(JSON_FORMAT_ENCODING);
            byte[] oldMd5Checksum = readOldMd5Checksum(oldState);
            byte[] newMd5Checksum = generateMd5Checksum(dataBytes);
            if (!Arrays.equals(oldMd5Checksum, newMd5Checksum)) {
                int dataSize = dataBytes.length;
                output.writeEntityHeader(JSON_FORMAT_HEADER_KEY, dataSize);
                output.writeEntityData(dataBytes, dataSize);

                Log.i(TAG, "Backup successful.");
            } else {
                Log.i(TAG, "Old and new MD5 checksums match. Skipping backup.");
            }

            writeNewMd5Checksum(newState, newMd5Checksum);
        } catch (JSONException | IOException | NoSuchAlgorithmException e) {
            Log.e(TAG, "Couldn't backup account sync settings\n" + e);
        }
    }

    /**
     * Fetch and serialize Account and authority information as a JSON Array.
     */
    private JSONObject serializeAccountSyncSettingsToJSON() throws JSONException {
        Account[] accounts = mAccountManager.getAccounts();
        SyncAdapterType[] syncAdapters = ContentResolver.getSyncAdapterTypesAsUser(
                mContext.getUserId());

        // Create a map of Account types to authorities. Later this will make it easier for us to
        // generate our JSON.
        HashMap<String, List<String>> accountTypeToAuthorities = new HashMap<String,
                List<String>>();
        for (SyncAdapterType syncAdapter : syncAdapters) {
            // Skip adapters that aren’t visible to the user.
            if (!syncAdapter.isUserVisible()) {
                continue;
            }
            if (!accountTypeToAuthorities.containsKey(syncAdapter.accountType)) {
                accountTypeToAuthorities.put(syncAdapter.accountType, new ArrayList<String>());
            }
            accountTypeToAuthorities.get(syncAdapter.accountType).add(syncAdapter.authority);
        }

        // Generate JSON.
        JSONObject backupJSON = new JSONObject();
        backupJSON.put(KEY_VERSION, JSON_FORMAT_VERSION);
        backupJSON.put(KEY_MASTER_SYNC_ENABLED, ContentResolver.getMasterSyncAutomatically());

        JSONArray accountJSONArray = new JSONArray();
        for (Account account : accounts) {
            List<String> authorities = accountTypeToAuthorities.get(account.type);

            // We ignore Accounts that don't have any authorities because there would be no sync
            // settings for us to restore.
            if (authorities == null || authorities.isEmpty()) {
                continue;
            }

            JSONObject accountJSON = new JSONObject();
            accountJSON.put(KEY_ACCOUNT_NAME, account.name);
            accountJSON.put(KEY_ACCOUNT_TYPE, account.type);

            // Add authorities for this Account type and check whether or not sync is enabled.
            JSONArray authoritiesJSONArray = new JSONArray();
            for (String authority : authorities) {
                int syncState = ContentResolver.getIsSyncable(account, authority);
                boolean syncEnabled = ContentResolver.getSyncAutomatically(account, authority);

                JSONObject authorityJSON = new JSONObject();
                authorityJSON.put(KEY_AUTHORITY_NAME, authority);
                authorityJSON.put(KEY_AUTHORITY_SYNC_STATE, syncState);
                authorityJSON.put(KEY_AUTHORITY_SYNC_ENABLED, syncEnabled);
                authoritiesJSONArray.put(authorityJSON);
            }
            accountJSON.put(KEY_ACCOUNT_AUTHORITIES, authoritiesJSONArray);

            accountJSONArray.put(accountJSON);
        }
        backupJSON.put(KEY_ACCOUNTS, accountJSONArray);

        return backupJSON;
    }

    /**
     * Read the MD5 checksum from the old state.
     *
     * @return the old MD5 checksum
     */
    private byte[] readOldMd5Checksum(ParcelFileDescriptor oldState) throws IOException {
        DataInputStream dataInput = new DataInputStream(
                new FileInputStream(oldState.getFileDescriptor()));

        byte[] oldMd5Checksum = new byte[MD5_BYTE_SIZE];
        try {
            int stateVersion = dataInput.readInt();
            if (stateVersion <= STATE_VERSION) {
                // If the state version is a version we can understand then read the MD5 sum,
                // otherwise we return an empty byte array for the MD5 sum which will force a
                // backup.
                for (int i = 0; i < MD5_BYTE_SIZE; i++) {
                    oldMd5Checksum[i] = dataInput.readByte();
                }
            } else {
                Log.i(TAG, "Backup state version is: " + stateVersion
                        + " (support only up to version " + STATE_VERSION + ")");
            }
        } catch (EOFException eof) {
            // Initial state may be empty.
        }
        // We explicitly don't close 'dataInput' because we must not close the backing fd.
        return oldMd5Checksum;
    }

    /**
     * Write the given checksum to the file descriptor.
     */
    private void writeNewMd5Checksum(ParcelFileDescriptor newState, byte[] md5Checksum)
            throws IOException {
        DataOutputStream dataOutput = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(newState.getFileDescriptor())));

        dataOutput.writeInt(STATE_VERSION);
        dataOutput.write(md5Checksum);

        // We explicitly don't close 'dataOutput' because we must not close the backing fd.
        // The FileOutputStream will not close it implicitly.

    }

    private byte[] generateMd5Checksum(byte[] data) throws NoSuchAlgorithmException {
        if (data == null) {
            return null;
        }

        MessageDigest md5 = MessageDigest.getInstance("MD5");
        return md5.digest(data);
    }

    /**
     * Restore account sync settings from the given data input stream.
     */
    @Override
    public void restoreEntity(BackupDataInputStream data) {
        byte[] dataBytes = new byte[data.size()];
        try {
            // Read the data and convert it to a String.
            data.read(dataBytes);
            String dataString = new String(dataBytes, JSON_FORMAT_ENCODING);

            // Convert data to a JSON object.
            JSONObject dataJSON = new JSONObject(dataString);
            boolean masterSyncEnabled = dataJSON.getBoolean(KEY_MASTER_SYNC_ENABLED);
            JSONArray accountJSONArray = dataJSON.getJSONArray(KEY_ACCOUNTS);

            boolean currentMasterSyncEnabled = ContentResolver.getMasterSyncAutomatically();
            if (currentMasterSyncEnabled) {
                // Disable master sync to prevent any syncs from running.
                ContentResolver.setMasterSyncAutomatically(false);
            }

            try {
                restoreFromJsonArray(accountJSONArray);
            } finally {
                // Set the master sync preference to the value from the backup set.
                ContentResolver.setMasterSyncAutomatically(masterSyncEnabled);
            }
            Log.i(TAG, "Restore successful.");
        } catch (IOException | JSONException e) {
            Log.e(TAG, "Couldn't restore account sync settings\n" + e);
        }
    }

    private void restoreFromJsonArray(JSONArray accountJSONArray)
            throws JSONException {
        HashSet<Account> currentAccounts = getAccounts();
        JSONArray unaddedAccountsJSONArray = new JSONArray();
        for (int i = 0; i < accountJSONArray.length(); i++) {
            JSONObject accountJSON = (JSONObject) accountJSONArray.get(i);
            String accountName = accountJSON.getString(KEY_ACCOUNT_NAME);
            String accountType = accountJSON.getString(KEY_ACCOUNT_TYPE);

            Account account = null;
            try {
                account = new Account(accountName, accountType);
            } catch (IllegalArgumentException iae) {
                continue;
            }

            // Check if the account already exists. Accounts that don't exist on the device
            // yet won't be restored.
            if (currentAccounts.contains(account)) {
                if (DEBUG) Log.i(TAG, "Restoring Sync Settings for" + accountName);
                restoreExistingAccountSyncSettingsFromJSON(accountJSON);
            } else {
                unaddedAccountsJSONArray.put(accountJSON);
            }
        }

        if (unaddedAccountsJSONArray.length() > 0) {
            try (FileOutputStream fOutput = new FileOutputStream(STASH_FILE)) {
                String jsonString = unaddedAccountsJSONArray.toString();
                DataOutputStream out = new DataOutputStream(fOutput);
                out.writeUTF(jsonString);
            } catch (IOException ioe) {
                // Error in writing to stash file
                Log.e(TAG, "unable to write the sync settings to the stash file", ioe);
            }
        } else {
            File stashFile = new File(STASH_FILE);
            if (stashFile.exists()) stashFile.delete();
        }
    }

    /**
     * Restore SyncSettings for all existing accounts from a stashed backup-set
     */
    private void accountAddedInternal() {
        String jsonString;

        try (FileInputStream fIn = new FileInputStream(new File(STASH_FILE))) {
            DataInputStream in = new DataInputStream(fIn);
            jsonString = in.readUTF();
        } catch (FileNotFoundException fnfe) {
            // This is expected to happen when there is no accounts info stashed
            if (DEBUG) Log.d(TAG, "unable to find the stash file", fnfe);
            return;
        } catch (IOException ioe) {
            if (DEBUG) Log.d(TAG, "could not read sync settings from stash file", ioe);
            return;
        }

        try {
            JSONArray unaddedAccountsJSONArray = new JSONArray(jsonString);
            restoreFromJsonArray(unaddedAccountsJSONArray);
        } catch (JSONException jse) {
            // Malformed jsonString
            Log.e(TAG, "there was an error with the stashed sync settings", jse);
        }
    }

    /**
     * Restore SyncSettings for all existing accounts from a stashed backup-set
     */
    public static void accountAdded(Context context) {
        AccountSyncSettingsBackupHelper helper = new AccountSyncSettingsBackupHelper(context);
        helper.accountAddedInternal();
    }

    /**
     * Helper method - fetch accounts and return them as a HashSet.
     *
     * @return Accounts in a HashSet.
     */
    private HashSet<Account> getAccounts() {
        Account[] accounts = mAccountManager.getAccounts();
        HashSet<Account> accountHashSet = new HashSet<Account>();
        for (Account account : accounts) {
            accountHashSet.add(account);
        }
        return accountHashSet;
    }

    /**
     * Restore account sync settings using the given JSON. This function won't work if the account
     * doesn't exist yet.
     * This function will only be called during Setup Wizard, where we are guaranteed that there
     * are no active syncs.
     * There are 2 pieces of data to restore -
     *      isSyncable (corresponds to {@link ContentResolver#getIsSyncable(Account, String)}
     *      syncEnabled (corresponds to {@link ContentResolver#getSyncAutomatically(Account, String)}
     * <strong>The restore favours adapters that were enabled on the old device, and doesn't care
     * about adapters that were disabled.</strong>
     *
     * syncEnabled=true in restore data.
     * syncEnabled will be true on this device. isSyncable will be left as the default in order to
     * give the enabled adapter the chance to run an initialization sync.
     *
     * syncEnabled=false in restore data.
     * syncEnabled will be false on this device. isSyncable will be set to 2, unless it was 0 on the
     * old device in which case it will be set to 0 on this device. This is because isSyncable=0 is
     * a rare state and was probably set to 0 for good reason (historically isSyncable is a way by
     * which adapters control their own sync state independently of sync settings which is
     * toggleable by the user).
     * isSyncable=2 is a new isSyncable state we introduced specifically to allow adapters that are
     * disabled after a restore to run initialization logic when the adapter is later enabled.
     * See com.android.server.content.SyncStorageEngine#setSyncAutomatically
     *
     * The end result is that an adapter that the user had on will be turned on and get an
     * initialization sync, while an adapter that the user had off will be off until the user
     * enables it on this device at which point it will get an initialization sync.
     */
    private void restoreExistingAccountSyncSettingsFromJSON(JSONObject accountJSON)
            throws JSONException {
        // Restore authorities.
        JSONArray authorities = accountJSON.getJSONArray(KEY_ACCOUNT_AUTHORITIES);
        String accountName = accountJSON.getString(KEY_ACCOUNT_NAME);
        String accountType = accountJSON.getString(KEY_ACCOUNT_TYPE);

        final Account account = new Account(accountName, accountType);
        for (int i = 0; i < authorities.length(); i++) {
            JSONObject authority = (JSONObject) authorities.get(i);
            final String authorityName = authority.getString(KEY_AUTHORITY_NAME);
            boolean wasSyncEnabled = authority.getBoolean(KEY_AUTHORITY_SYNC_ENABLED);
            int wasSyncable = authority.getInt(KEY_AUTHORITY_SYNC_STATE);

            ContentResolver.setSyncAutomaticallyAsUser(
                    account, authorityName, wasSyncEnabled, 0 /* user Id */);

            if (!wasSyncEnabled) {
                ContentResolver.setIsSyncable(
                        account,
                        authorityName,
                        wasSyncable == 0 ?
                                0 /* not syncable */ : 2 /* syncable but needs initialization */);
            }
        }
    }

    @Override
    public void writeNewStateDescription(ParcelFileDescriptor newState) {

    }
}