/*
 * Copyright (C) 2014 Murray Cumming
 *
 * This file is part of android-galaxyzoo
 *
 * android-galaxyzoo is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as published by the
 * Free Software Foundation, either version 3 of the License, or (at your
 * option) any later version.
 *
 * android-galaxyzoo is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
 * for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with android-galaxyzoo.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.murrayc.galaxyzoo.app;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.FragmentActivity;
import android.text.TextUtils;
import android.util.JsonReader;

import com.murrayc.galaxyzoo.app.provider.Item;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.WeakReference;
import java.util.Map;

/**
 * Created by murrayc on 10/5/14.
 */
public final class LoginUtils {
    // An account type, in the form of a domain name
    // This must match the android:accountType in authenticator.xml
    public static final String ACCOUNT_TYPE = "galaxyzoo.com";

    //This is an arbitrary string, because Accountmanager.setAuthToken() needs something non-null
    public static final String ACCOUNT_AUTHTOKEN_TYPE = "authApiKey";

    //Because the Account must have a name.
    //TODO: Stop this string from appearing in the general Settings->Accounts UI,
    //or at least show a translatable "Anonymous" there instead.
    private static final String ACCOUNT_NAME_ANONYMOUS = "anonymous";

    /**
     * Returns true if we have a real account that has logged into the server,
     * or false if we are using the anonymous account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @return
     */
    public static boolean getLoggedIn(final Context context) {
        final LoginDetails loginDetails = getAccountLoginDetails(context);
        return getLoggedIn(loginDetails);
    }

    /**
     * This is a just a utility method that examines the LoginDetails.
     * Unlike getLoggedIn(Context), this can be called from any thread.
     *
     * @param loginDetails
     * @return
     */
    public static boolean getLoggedIn(final LoginDetails loginDetails) {
        if (loginDetails == null) {
            return false;
        }

        return !(TextUtils.isEmpty(loginDetails.authApiKey));
    }

    public static LoginResult parseLoginResponseContent(final InputStream content) throws IOException {
        //A failure by default.
        LoginResult result = new LoginResult(false, null, null);

        final InputStreamReader streamReader = new InputStreamReader(content, Utils.STRING_ENCODING);
        final JsonReader reader = new JsonReader(streamReader);
        reader.beginObject();
        boolean success = false;
        String apiKey = null;
        String userName = null;
        String message = null;
        while (reader.hasNext()) {
            final String name = reader.nextName();
            switch (name) {
                case "success":
                    success = reader.nextBoolean();
                    break;
                case "api_key":
                    apiKey = reader.nextString();
                    break;
                case "name":
                    userName = reader.nextString();
                    break;
                case "message":
                    message = reader.nextString();
                    break;
                default:
                    reader.skipValue();
            }
        }

        if (success) {
            result = new LoginResult(true, userName, apiKey);
        } else {
            Log.info("Login failed.");
            Log.info("Login failure message: " + message);
        }

        reader.endObject();
        reader.close();

        streamReader.close();

        return result;
    }

    /**
     * This returns null if there is no account (not even an anonymous account).
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @return
     */
    @Nullable
    public static LoginDetails getAccountLoginDetails(final Context context) {
        final AccountManager mgr = AccountManager.get(context);
        if (mgr == null) {
            Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because AccountManager.get() returned null.");
            return null;
        }

        final Account account = getAccount(mgr);
        if (account == null) {
            Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because getAccount() returned null. ");
            return null;
        }

        //Make sure that this has not been unset somehow:
        setAutomaticAccountSync(context, account);

        final LoginDetails result = new LoginDetails();


        //Avoid showing our anonymous account name in the UI.
        //Also, an anonymous account never has an auth_api_key.
        result.isAnonymous = TextUtils.equals(account.name, ACCOUNT_NAME_ANONYMOUS);
        if (result.isAnonymous) {
            return result; //Return a mostly-empty empty (but not null) LoginDetails.
        }

        result.name = account.name;

        //Note that this requires the USE_CREDENTIALS permission on
        //SDK <=22.
        final AccountManagerFuture<Bundle> response = mgr.getAuthToken(account, ACCOUNT_AUTHTOKEN_TYPE, null, null, null, null);
        try {
            final Bundle bundle = response.getResult();
            if (bundle == null) {
                //TODO: Let the caller catch this?
                Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because getAuthToken() returned a null response result bundle.");
                return null;
            }

            result.authApiKey = bundle.getString(AccountManager.KEY_AUTHTOKEN);
            return result;
        } catch (final OperationCanceledException e) {
            //TODO: Let the caller catch this?
            Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e);
            return null;
        } catch (final AuthenticatorException e) {
            //TODO: Let the caller catch this?
            Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e);
            return null;
        } catch (final IOException e) {
            //TODO: Let the caller catch this?
            Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e);
            return null;
        }
    }

    public static void logOut(final ZooFragment fragment) {
        final Activity activity = fragment.getActivity();
        final AccountRemoveTask task = new AccountRemoveTask(activity) {
            @Override
            protected void onPostExecute(final Void result) {
                super.onPostExecute(result);

                //Make sure that the currently-shown menu will update:
                ZooFragment.setCachedLoggedIn(false);

                //TODO: This doesn't actually seem to cause the (various) child fragments'
                //onPrepareOptionsMenu() methods to be called. Maybe it doesn't work with
                //nested child fragments.
                if (activity instanceof FragmentActivity) {
                    final FragmentActivity fragmentActivity = (FragmentActivity) activity;
                    fragmentActivity.supportInvalidateOptionsMenu();
                } else {
                    activity.invalidateOptionsMenu();
                }
            }
        };
        task.execute();
    }

    /**
     * Add the anonymous Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     * @param context
     */
    private static void addAnonymousAccount(final Context context) {
        final AccountManager accountManager = AccountManager.get(context);
        final Account account = new Account(ACCOUNT_NAME_ANONYMOUS, LoginUtils.ACCOUNT_TYPE);
        //Note that this requires the AUTHENTICATE_ACCOUNTS permission on
        //SDK <=22:
        accountManager.addAccountExplicitly(account, null, null);

        //In case it has not been called yet.
        //This has no effect the second time.
        Utils.initDefaultPrefs(context);

        //Give the new account the existing (probably default) preferences,
        //so the SyncAdapter can use them.
        //See SettingsFragment.onSharedPreferenceChanged().
        copyPrefsToAccount(context, accountManager, account);

        //Tell the SyncAdapter to sync whenever the network is reconnected:
        setAutomaticAccountSync(context, account);
    }

    static void setAutomaticAccountSync(final Context context, final Account account) {
        final ContentResolver resolver = context.getContentResolver();
        if (resolver == null) {
            return;
        }

        ContentResolver.setSyncAutomatically(account, Item.AUTHORITY, true);
    }

    /** Don't call this from the main UI thread.
     *
     * @param context
     */
    public static void removeAnonymousAccount(final Context context) {
        removeAccount(context, ACCOUNT_NAME_ANONYMOUS);
    }

    /** Don't call this from the main UI thread.
     *
     * @param context
     */
    public static void removeAccount(final Context context, final String accountName) {
        final AccountManager accountManager = AccountManager.get(context);
        final Account account = new Account(accountName, LoginUtils.ACCOUNT_TYPE);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
            //Trying to call this on an older Android version results in a
            //NoSuchMethodError exception.
            //There is no AppCompat version of the AccountManager API to
            //avoid the need for this version check at runtime.
            accountManager.removeAccount(account, null, null, null);
        } else {
            //noinspection deprecation
            //Note that this needs the MANAGE_ACCOUNT permission on
            //SDK <=22.
            //noinspection deprecation
            accountManager.removeAccount(account, null, null);
        }
    }

    /**
     * Get a preference from the Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @param prefKeyResId
     * @return
     */
    private static boolean getBooleanPref(final Context context, final int prefKeyResId) {
        final String value = getStringPref(context, prefKeyResId);
        if (value == null) {
            return false;
        }

        return Boolean.parseBoolean(value);
    }

    /**
     * Get a preference from the Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @param prefKeyResId
     * @return
     */
    public static int getIntPref(final Context context, final int prefKeyResId) {
        final String value = getStringPref(context, prefKeyResId);
        if (value == null) {
            return 0;
        }

        try {
            return Integer.parseInt(value);
        } catch (final NumberFormatException e) {
            //NumberFormatException is an unchecked exception but
            //it would not be a programmer error to try to parse
            //an input string (the stored preference in this case)
            //as an Integer, as long as there's no way for us
            //to check its validity before calling Integer.parseInt().
            //Therefore we catch it.
            return 0;
        }
    }

    /**
     * Get a preference from the Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @param prefKeyResId
     * @return
     */
    @Nullable
    private static String getStringPref(final Context context, final int prefKeyResId) {
        final AccountManager mgr = AccountManager.get(context);
        final Account account = getAccount(context);
        if (account == null) {
            return null;
        }

        //Note that this requires the AUTHENTICATE_ACCOUNTS permission on
        //SDK <=22.
        return mgr.getUserData(account, context.getString(prefKeyResId));
    }

    /**
     * Get the Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param mgr
     * @return
     */
    @Nullable
    private static Account getAccount(final AccountManager mgr) {
        //Note this needs the GET_ACCOUNTS permission on
        //SDK <=22
        // Ignore android-lint warnings about this: https://code.google.com/p/android/issues/detail?id=223244
        final Account[] accts = mgr.getAccountsByType(ACCOUNT_TYPE);
        if((accts == null) || (accts.length < 1)) {
            //Log.error("getAccountLoginDetails(): getAccountsByType() returned no account.");
            return null;
        }

        return accts[0];
    }

    /**
     * Get the Account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     *
     * @param context
     * @return
     */
    private static Account getAccount(final Context context) {
        final AccountManager mgr = AccountManager.get(context);
        return getAccount(mgr);
    }

    static void copyPrefToAccount(final Context context, final String key, final String value) {
        //Copy the preference to the Account:
        final AccountManager mgr = AccountManager.get(context);
        final Account account = getAccount(context);
        if (account == null) {
            return;
        }

        copyPrefToAccount(mgr, account, key, value);
    }

    private static void copyPrefToAccount(final AccountManager mgr, final Account account, final String key, final String value) {
        //Note that this requires the AUTHENTICATE_ACCOUNTS permission on
        //SDK <=22.
        mgr.setUserData(account, key, value);
    }

    static void copyPrefsToAccount(final Context context, final AccountManager accountManager, final Account account) {
        //Copy the preferences into the account.
        //See also SettingsFragment.onSharedPreferenceChanged()
        final SharedPreferences prefs = Utils.getPreferences(context);
        final Map<String, ?> keys = prefs.getAll();
        for(final Map.Entry<String, ?> entry : keys.entrySet()) {
            final Object value =  entry.getValue();
            if (value instanceof String) {
                copyPrefToAccount(accountManager, account, entry.getKey(), (String) value);
            } else if (value instanceof Integer) {
                copyPrefToAccount(accountManager, account, entry.getKey(), Integer.toString((Integer) value));
            } else if (value instanceof Boolean) {
                copyPrefToAccount(accountManager, account, entry.getKey(), Boolean.toString((Boolean) value));
            }
        }
    }

    /**
     * Get the "use-wifi only" setting from the account.
     *
     * Don't call this from the main thread - use an AsyncTask, for instance.
     * Or use Utils.getUseWifiOnlyFromSharedPrefs().
     *
     * @param context
     * @return
     */
    public static boolean getUseWifiOnly(final Context context) {
        return getBooleanPref(context, R.string.pref_key_wifi_only);
    }

    public static class LoginDetails {
        public String name = null;
        public String authApiKey = null;
        public boolean isAnonymous = false;
    }

    /**
     * Represents an asynchronous login/registration task used to authenticate
     * the user.
     */
    public static class GetExistingLogin extends AsyncTask<Void, Void, LoginDetails> {

        private final WeakReference<Context> mContextReference;
        Exception mException = null;

        GetExistingLogin(final Context context) {
            mContextReference = new WeakReference<>(context);
        }

        @Override
        protected LoginDetails doInBackground(final Void... params) {

            if (mContextReference == null) {
                return null;
            }

            final Context context = mContextReference.get();
            if (context == null) {
                return null;
            }

            if (isCancelled()) {
                return null;
            }

            LoginDetails result = null;

            try {
                result = getAccountLoginDetails(context);
                if (result == null) {
                    //Add an anonymous Account,
                    //because our SyncAdapter will not run if there is no associated Account,
                    //and we want it to run to get the items to classify, and to upload
                    //anonymous classifications.
                    LoginUtils.addAnonymousAccount(context);

                    return getAccountLoginDetails(context);
                }
            } catch (final SecurityException ex) {
                mException = ex;
            }

            return result;
        }

        @Override
        protected void onCancelled() {
        }
    }

    /** Run this to log out.
     */
    private static class AccountRemoveTask extends AsyncTask<Void, Void, Void> {

        private final WeakReference<Context> contextReference;

        AccountRemoveTask(final Context context) {
            this.contextReference = new WeakReference<>(context);
        }

        @Override
        protected Void doInBackground(final Void... params) {

            if (contextReference == null) {
                return null;
            }

            final Context context = contextReference.get();
            if (context == null) {
                return null;
            }

            if (isCancelled()) {
                return null;
            }

            final LoginUtils.LoginDetails loginDetails = LoginUtils.getAccountLoginDetails(context);
            if(!LoginUtils.getLoggedIn(loginDetails)) {
                return null;
            }

            final String accountName = loginDetails.name;
            if (TextUtils.isEmpty(accountName)) {
                return null;
            }

            if (isCancelled()) {
                return null;
            }

            LoginUtils.removeAccount(context, accountName);

            LoginUtils.addAnonymousAccount(context);

            return null;
        }
    }


    public static class LoginResult {
        private final boolean success;
        private final String name;
        private final String apiKey;

        public LoginResult(final boolean success, final String name, final String apiKey) {
            this.success = success;
            this.name = name;
            this.apiKey = apiKey;
        }

        public String getApiKey() {
            return apiKey;
        }

        public boolean getSuccess() {
            return success;
        }

        public String getName() {
            return name;
        }
    }
}