package com.auth0.android.provider;

import android.app.Activity;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;

import com.auth0.android.Auth0;
import com.auth0.android.authentication.AuthenticationAPIClient;
import com.auth0.android.authentication.AuthenticationException;
import com.auth0.android.callback.BaseCallback;
import com.auth0.android.jwt.DecodeException;
import com.auth0.android.jwt.JWT;
import com.auth0.android.result.Credentials;

import java.security.SecureRandom;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@SuppressWarnings("WeakerAccess")
class OAuthManager extends ResumableManager {

    private static final String TAG = OAuthManager.class.getSimpleName();

    static final String KEY_RESPONSE_TYPE = "response_type";
    static final String KEY_STATE = "state";
    static final String KEY_NONCE = "nonce";
    static final String KEY_MAX_AGE = "max_age";
    static final String KEY_CONNECTION = "connection";
    static final String RESPONSE_TYPE_ID_TOKEN = "id_token";
    static final String RESPONSE_TYPE_CODE = "code";

    private static final String ERROR_VALUE_INVALID_CONFIGURATION = "a0.invalid_configuration";
    private static final String ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled";
    private static final String ERROR_VALUE_ACCESS_DENIED = "access_denied";
    private static final String ERROR_VALUE_UNAUTHORIZED = "unauthorized";
    private static final String ERROR_VALUE_LOGIN_REQUIRED = "login_required";
    private static final String ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED = "Could not verify the ID token";
    private static final String METHOD_SHA_256 = "S256";
    private static final String KEY_CODE_CHALLENGE = "code_challenge";
    private static final String KEY_CODE_CHALLENGE_METHOD = "code_challenge_method";
    private static final String KEY_CLIENT_ID = "client_id";
    private static final String KEY_REDIRECT_URI = "redirect_uri";
    private static final String KEY_TELEMETRY = "auth0Client";
    private static final String KEY_ERROR = "error";
    private static final String KEY_ERROR_DESCRIPTION = "error_description";
    private static final String KEY_ID_TOKEN = "id_token";
    private static final String KEY_ACCESS_TOKEN = "access_token";
    private static final String KEY_TOKEN_TYPE = "token_type";
    private static final String KEY_EXPIRES_IN = "expires_in";
    private static final String KEY_CODE = "code";
    private static final String KEY_SCOPE = "scope";

    private final Auth0 account;
    private final AuthCallback callback;
    private final Map<String, String> parameters;
    private final AuthenticationAPIClient apiClient;

    private boolean useFullScreen;
    private boolean useBrowser = true;
    private int requestCode;
    private PKCE pkce;
    private Long currentTimeInMillis;
    private CustomTabsOptions ctOptions;
    private Integer idTokenVerificationLeeway;

    OAuthManager(@NonNull Auth0 account, @NonNull AuthCallback callback, @NonNull Map<String, String> parameters) {
        this.account = account;
        this.callback = callback;
        this.parameters = new HashMap<>(parameters);
        this.apiClient = new AuthenticationAPIClient(account);
    }

    void useFullScreen(boolean useFullScreen) {
        this.useFullScreen = useFullScreen;
    }

    void useBrowser(boolean useBrowser) {
        this.useBrowser = useBrowser;
    }

    public void setCustomTabsOptions(@Nullable CustomTabsOptions options) {
        this.ctOptions = options;
    }

    @VisibleForTesting
    void setPKCE(PKCE pkce) {
        this.pkce = pkce;
    }

    void setIdTokenVerificationLeeway(Integer leeway) {
        this.idTokenVerificationLeeway = leeway;
    }

    void startAuthentication(Activity activity, String redirectUri, int requestCode) {
        addPKCEParameters(parameters, redirectUri);
        addClientParameters(parameters, redirectUri);
        addValidationParameters(parameters);
        Uri uri = buildAuthorizeUri();
        this.requestCode = requestCode;

        if (useBrowser) {
            AuthenticationActivity.authenticateUsingBrowser(activity, uri, ctOptions);
        } else {
            AuthenticationActivity.authenticateUsingWebView(activity, uri, requestCode, parameters.get(KEY_CONNECTION), useFullScreen);
        }
    }

    @Override
    boolean resume(AuthorizeResult result) {
        if (!result.isValid(requestCode)) {
            Log.w(TAG, "The Authorize Result is invalid.");
            return false;
        }

        if (result.isCanceled()) {
            //User cancelled the authentication
            AuthenticationException exception = new AuthenticationException(ERROR_VALUE_AUTHENTICATION_CANCELED, "The user closed the browser app and the authentication was canceled.");
            callback.onFailure(exception);
            return true;
        }

        final Map<String, String> values = CallbackHelper.getValuesFromUri(result.getIntentData());
        if (values.isEmpty()) {
            Log.w(TAG, "The response didn't contain any of these values: code, state, id_token, access_token, token_type, refresh_token");
            return false;
        }
        logDebug("The parsed CallbackURI contains the following values: " + values);

        try {
            assertNoError(values.get(KEY_ERROR), values.get(KEY_ERROR_DESCRIPTION));
            assertValidState(parameters.get(KEY_STATE), values.get(KEY_STATE));
        } catch (AuthenticationException e) {
            callback.onFailure(e);
            return true;
        }

        final Date expiresAt = !values.containsKey(KEY_EXPIRES_IN) ? null : new Date(getCurrentTimeInMillis() + Long.valueOf(values.get(KEY_EXPIRES_IN)) * 1000);
        boolean frontChannelIdTokenExpected = parameters.containsKey(KEY_RESPONSE_TYPE) && parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN);
        final Credentials frontChannelCredentials = new Credentials(frontChannelIdTokenExpected ? values.get(KEY_ID_TOKEN) : null, values.get(KEY_ACCESS_TOKEN), values.get(KEY_TOKEN_TYPE), null, expiresAt, values.get(KEY_SCOPE));

        if (frontChannelIdTokenExpected) {
            //Must be response_type=id_token (or additional values)
            assertValidIdToken(frontChannelCredentials.getIdToken(), new BaseCallback<Void, TokenValidationException>() {
                @Override
                public void onSuccess(Void ignored) {
                    if (!shouldUsePKCE()) {
                        //response_type=id_token or response_type=id_token token
                        callback.onSuccess(frontChannelCredentials);
                        return;
                    }
                    //response_type=id_token code
                    pkce.getToken(values.get(KEY_CODE), new SimpleAuthCallback(callback) {

                        @Override
                        public void onSuccess(@NonNull Credentials credentials) {
                            Credentials finalCredentials = mergeCredentials(frontChannelCredentials, credentials);
                            callback.onSuccess(finalCredentials);
                        }
                    });
                }

                @Override
                public void onFailure(TokenValidationException error) {
                    AuthenticationException wrappedError = new AuthenticationException(ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error);
                    callback.onFailure(wrappedError);
                }
            });
            return true;
        }

        if (!shouldUsePKCE()) {
            //Must be response_type=token
            callback.onSuccess(frontChannelCredentials);
            return true;
        }

        //Either response_type=code or response_type=token code
        pkce.getToken(values.get(KEY_CODE), new SimpleAuthCallback(callback) {

            @Override
            public void onSuccess(@NonNull final Credentials credentials) {
                assertValidIdToken(credentials.getIdToken(), new BaseCallback<Void, TokenValidationException>() {
                    @Override
                    public void onSuccess(Void ignored) {
                        Credentials finalCredentials = mergeCredentials(frontChannelCredentials, credentials);
                        callback.onSuccess(finalCredentials);
                    }

                    @Override
                    public void onFailure(TokenValidationException error) {
                        AuthenticationException wrappedError = new AuthenticationException(ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error);
                        callback.onFailure(wrappedError);
                    }
                });
            }
        });
        return true;
    }

    private void assertValidIdToken(String idToken, final BaseCallback<Void, TokenValidationException> validationCallback) {
        if (TextUtils.isEmpty(idToken)) {
            validationCallback.onFailure(new TokenValidationException("ID token is required but missing"));
            return;
        }
        final JWT decodedIdToken;
        try {
            decodedIdToken = new JWT(idToken);
        } catch (DecodeException ignored) {
            validationCallback.onFailure(new TokenValidationException("ID token could not be decoded"));
            return;
        }

        BaseCallback<SignatureVerifier, TokenValidationException> signatureVerifierCallback = new BaseCallback<SignatureVerifier, TokenValidationException>() {

            @Override
            public void onFailure(TokenValidationException error) {
                validationCallback.onFailure(error);
            }

            @Override
            public void onSuccess(SignatureVerifier signatureVerifier) {
                IdTokenVerificationOptions options = new IdTokenVerificationOptions(apiClient.getBaseURL(), apiClient.getClientId(), signatureVerifier);
                String maxAge = parameters.get(KEY_MAX_AGE);
                if (!TextUtils.isEmpty(maxAge)) {
                    //noinspection ConstantConditions
                    options.setMaxAge(Integer.valueOf(maxAge));
                }
                options.setClockSkew(idTokenVerificationLeeway);
                options.setNonce(parameters.get(KEY_NONCE));
                options.setClock(new Date(getCurrentTimeInMillis()));
                try {
                    new IdTokenVerifier().verify(decodedIdToken, options);
                    logDebug("Authenticated using web flow");
                    validationCallback.onSuccess(null);
                } catch (TokenValidationException exc) {
                    validationCallback.onFailure(exc);
                }
            }
        };

        String tokenAlg = decodedIdToken.getHeader().get("alg");
        if (account.isOIDCConformant() || "RS256".equals(tokenAlg)) {
            String tokenKeyId = decodedIdToken.getHeader().get("kid");
            SignatureVerifier.forAsymmetricAlgorithm(tokenKeyId, apiClient, signatureVerifierCallback);
        } else {
            SignatureVerifier.forUnknownAlgorithm(signatureVerifierCallback);
        }
    }

    private long getCurrentTimeInMillis() {
        return currentTimeInMillis != null ? currentTimeInMillis : System.currentTimeMillis();
    }

    @VisibleForTesting
    void setCurrentTimeInMillis(long currentTimeInMillis) {
        this.currentTimeInMillis = currentTimeInMillis;
    }

    //Helper Methods

    private void assertNoError(String errorValue, String errorDescription) throws AuthenticationException {
        if (errorValue == null) {
            return;
        }
        Log.e(TAG, "Error, access denied. Check that the required Permissions are granted and that the Application has this Connection configured in Auth0 Dashboard.");
        if (ERROR_VALUE_ACCESS_DENIED.equalsIgnoreCase(errorValue)) {
            throw new AuthenticationException(ERROR_VALUE_ACCESS_DENIED, "Permissions were not granted. Try again.");
        } else if (ERROR_VALUE_UNAUTHORIZED.equalsIgnoreCase(errorValue)) {
            throw new AuthenticationException(ERROR_VALUE_UNAUTHORIZED, errorDescription);
        } else if (ERROR_VALUE_LOGIN_REQUIRED.equals(errorValue)) {
            //Whitelist to allow SSO errors go through
            throw new AuthenticationException(errorValue, errorDescription);
        } else {
            throw new AuthenticationException(ERROR_VALUE_INVALID_CONFIGURATION, "The application isn't configured properly for the social connection. Please check your Auth0's application configuration");
        }
    }

    @VisibleForTesting
    static void assertValidState(@NonNull String requestState, @Nullable String responseState) throws AuthenticationException {
        if (!requestState.equals(responseState)) {
            Log.e(TAG, String.format("Received state doesn't match. Received %s but expected %s", responseState, requestState));
            throw new AuthenticationException(ERROR_VALUE_ACCESS_DENIED, "The received state is invalid. Try again.");
        }
    }

    private Uri buildAuthorizeUri() {
        Uri authorizeUri = Uri.parse(account.getAuthorizeUrl());
        Uri.Builder builder = authorizeUri.buildUpon();
        for (Map.Entry<String, String> entry : parameters.entrySet()) {
            builder.appendQueryParameter(entry.getKey(), entry.getValue());
        }
        Uri uri = builder.build();
        logDebug("Using the following Authorize URI: " + uri.toString());
        return uri;
    }

    private void addPKCEParameters(Map<String, String> parameters, String redirectUri) {
        if (!shouldUsePKCE()) {
            return;
        }
        try {
            createPKCE(redirectUri);
            String codeChallenge = pkce.getCodeChallenge();
            parameters.put(KEY_CODE_CHALLENGE, codeChallenge);
            parameters.put(KEY_CODE_CHALLENGE_METHOD, METHOD_SHA_256);
            Log.v(TAG, "Using PKCE authentication flow");
        } catch (IllegalStateException e) {
            Log.e(TAG, "Some algorithms aren't available on this device and PKCE can't be used. Defaulting to token response_type.", e);
        }
    }

    private void addValidationParameters(Map<String, String> parameters) {
        String state = getRandomString(parameters.get(KEY_STATE));
        parameters.put(KEY_STATE, state);

        boolean idTokenExpected = parameters.containsKey(KEY_RESPONSE_TYPE) && (parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN) || parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_CODE));
        if (idTokenExpected) {
            String nonce = getRandomString(parameters.get(KEY_NONCE));
            parameters.put(KEY_NONCE, nonce);
        }
    }

    private void addClientParameters(Map<String, String> parameters, String redirectUri) {
        if (account.getTelemetry() != null) {
            parameters.put(KEY_TELEMETRY, account.getTelemetry().getValue());
        }
        parameters.put(KEY_CLIENT_ID, account.getClientId());
        parameters.put(KEY_REDIRECT_URI, redirectUri);
    }

    private void createPKCE(String redirectUri) {
        if (pkce == null) {
            pkce = new PKCE(apiClient, redirectUri);
        }
    }

    private boolean shouldUsePKCE() {
        return parameters.containsKey(KEY_RESPONSE_TYPE) && parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_CODE) && PKCE.isAvailable();
    }

    @VisibleForTesting
    boolean useBrowser() {
        return useBrowser;
    }

    @VisibleForTesting
    boolean useFullScreen() {
        return useFullScreen;
    }

    @VisibleForTesting
    CustomTabsOptions customTabsOptions() {
        return ctOptions;
    }

    @VisibleForTesting
    static Credentials mergeCredentials(Credentials urlCredentials, Credentials codeCredentials) {
        final String idToken = TextUtils.isEmpty(urlCredentials.getIdToken()) ? codeCredentials.getIdToken() : urlCredentials.getIdToken();
        final String accessToken = TextUtils.isEmpty(codeCredentials.getAccessToken()) ? urlCredentials.getAccessToken() : codeCredentials.getAccessToken();
        final String type = TextUtils.isEmpty(codeCredentials.getType()) ? urlCredentials.getType() : codeCredentials.getType();
        final String refreshToken = codeCredentials.getRefreshToken();
        final Date expiresAt = codeCredentials.getExpiresAt() != null ? codeCredentials.getExpiresAt() : urlCredentials.getExpiresAt();
        final String scope = TextUtils.isEmpty(codeCredentials.getScope()) ? urlCredentials.getScope() : codeCredentials.getScope();

        return new Credentials(idToken, accessToken, type, refreshToken, expiresAt, scope);
    }

    @VisibleForTesting
    static String getRandomString(@Nullable String defaultValue) {
        return defaultValue != null ? defaultValue : secureRandomString();
    }

    private static String secureRandomString() {
        final SecureRandom sr = new SecureRandom();
        final byte[] randomBytes = new byte[32];
        sr.nextBytes(randomBytes);
        return Base64.encodeToString(randomBytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
    }

    private void logDebug(String message) {
        if (account.isLoggingEnabled()) {
            Log.d(TAG, message);
        }
    }
}