/* -*- Mode: Java; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil -*- */
/* vim: set ts=2 et sw=2 tw=100: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.credentials.CreateCredentialException;
import android.credentials.CreateCredentialRequest;
import android.credentials.CreateCredentialResponse;
import android.credentials.CredentialManager;
import android.credentials.CredentialOption;
import android.credentials.GetCredentialException;
import android.credentials.GetCredentialRequest;
import android.credentials.GetCredentialResponse;
import android.credentials.PrepareGetCredentialResponse;
import android.os.Build;
import android.os.Bundle;
import android.os.OutcomeReceiver;
import android.util.Base64;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.WebAuthnUtils;
import org.mozilla.geckoview.GeckoResult;

// Credential Manager implementation that is introduced from Android 14.
public class WebAuthnCredentialManager {
  private static final String LOGTAG = "WebAuthnCredMan";
  private static final boolean DEBUG = false;

  // This defines are from androidx.credentials.
  // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:credentials/credentials/src/main/java/androidx/credentials/
  private static final String CM_PREFIX = "androidx.credentials.";
  private static final String TYPE_PUBLIC_KEY_CREDENTIAL = CM_PREFIX + "TYPE_PUBLIC_KEY_CREDENTIAL";
  private static final String BUNDLE_KEY_CLIENT_DATA_HASH =
      CM_PREFIX + "BUNDLE_KEY_CLIENT_DATA_HASH";
  private static final String BUNDLE_KEY_REGISTRATION_RESPONSE_JSON =
      CM_PREFIX + "BUNDLE_KEY_REGISTRATION_RESPONSE_JSON";
  private static final String BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON =
      CM_PREFIX + "BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON";
  private static final String BUNDLE_KEY_REQUEST_JSON = CM_PREFIX + "BUNDLE_KEY_REQUEST_JSON";
  private static final String BUNDLE_KEY_REQUEST_DISPLAY_INFO =
      CM_PREFIX + "BUNDLE_KEY_REQUEST_DISPLAY_INFO";
  private static final String BUNDLE_KEY_SUBTYPE = CM_PREFIX + "BUNDLE_KEY_SUBTYPE";
  private static final String BUNDLE_KEY_USER_DISPLAY_NAME =
      CM_PREFIX + "BUNDLE_KEY_USER_DISPLAY_NAME";
  private static final String BUNDLE_KEY_USER_ID = CM_PREFIX + "BUNDLE_KEY_USER_ID";
  private static final String BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST =
      CM_PREFIX + "BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST";
  private static final String BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION =
      CM_PREFIX + "BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION";
  private static final String BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED =
      CM_PREFIX + "BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED";
  private static final String BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS =
      CM_PREFIX + "BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS";

  private static Bundle getRequestBundle(final String requestJSON, final byte[] clientDataHash) {
    final Bundle requestBundle = new Bundle();
    requestBundle.putString(BUNDLE_KEY_REQUEST_JSON, requestJSON);
    requestBundle.putByteArray(BUNDLE_KEY_CLIENT_DATA_HASH, clientDataHash);

    return requestBundle;
  }

  private static Bundle getRequestBundleForMakeCredential(
      final GeckoBundle credentialBundle,
      final byte[] userId,
      final byte[] challenge,
      final int[] algs,
      final WebAuthnUtils.WebAuthnPublicCredential[] excludeList,
      final GeckoBundle authenticatorSelection,
      final GeckoBundle extensions,
      final byte[] clientDataHash) {
    try {
      final JSONObject requestJSON =
          WebAuthnUtils.getJSONObjectForMakeCredential(
              credentialBundle,
              userId,
              challenge,
              algs,
              excludeList,
              authenticatorSelection,
              extensions);
      final Bundle bundle = getRequestBundle(requestJSON.toString(), clientDataHash);
      if (bundle == null) {
        return null;
      }

      final Bundle displayInfoBundle = new Bundle();
      displayInfoBundle.putCharSequence(
          BUNDLE_KEY_USER_ID,
          Base64.encodeToString(userId, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP));
      final GeckoBundle userBundle = credentialBundle.getBundle("user");
      displayInfoBundle.putString(
          BUNDLE_KEY_USER_DISPLAY_NAME, userBundle.getString("displayName", ""));
      bundle.putBundle(BUNDLE_KEY_REQUEST_DISPLAY_INFO, displayInfoBundle);

      bundle.putString(
          BUNDLE_KEY_SUBTYPE, BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST);
      return bundle;
    } catch (final JSONException e) {
      Log.e(LOGTAG, "Couldn't generate JSON object for request", e);
    }
    return null;
  }

  private static Bundle getRequestBundleForGetAssertion(
      final byte[] challenge,
      final WebAuthnUtils.WebAuthnPublicCredential[] allowList,
      final GeckoBundle assertionBundle,
      final GeckoBundle extensionsBundle,
      final byte[] clientDataHash) {
    try {
      final JSONObject requestJSON =
          WebAuthnUtils.getJSONObjectForGetAssertion(
              challenge, allowList, assertionBundle, extensionsBundle);
      final Bundle bundle = getRequestBundle(requestJSON.toString(), clientDataHash);
      if (bundle == null) {
        return null;
      }
      bundle.putString(BUNDLE_KEY_SUBTYPE, BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION);
      return bundle;
    } catch (final JSONException e) {
      Log.e(LOGTAG, "Couldn't generate JSON object for request", e);
    }
    return null;
  }

  @SuppressLint("MissingPermission")
  public static GeckoResult<WebAuthnUtils.MakeCredentialResponse> makeCredential(
      final GeckoBundle credentialBundle,
      final byte[] userId,
      final byte[] challenge,
      final int[] algs,
      final WebAuthnUtils.WebAuthnPublicCredential[] excludeList,
      final GeckoBundle authenticatorSelection,
      final GeckoBundle extensions,
      final byte[] clientDataHash) {

    // We use Credential Manager first. If it doesn't work, we use GMS FIDO2.
    // Credential manager may support non-discoverable keys,
    // Else, following the specifications, `residentKey=discouraged` allows discoverable keys too
    // but prefer non-discoverable keys
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
      return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
    }
    final Context context = GeckoAppShell.getApplicationContext();
    // Some vendors disabled Credential Manager on Android 14+ devices.
    // https://issuetracker.google.com/issues/349310440
    if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CREDENTIALS)) {
      Log.w(LOGTAG, "Credential Manager is disabled on this device");
      return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
    }

    final Bundle requestBundle =
        getRequestBundleForMakeCredential(
            credentialBundle,
            userId,
            challenge,
            algs,
            excludeList,
            authenticatorSelection,
            extensions,
            clientDataHash);
    if (requestBundle == null) {
      return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
    }

    requestBundle.putBoolean(BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS, false);
    requestBundle.putBoolean(BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED, false);

    final CreateCredentialRequest request =
        new CreateCredentialRequest.Builder(
                TYPE_PUBLIC_KEY_CREDENTIAL, requestBundle, requestBundle)
            .setAlwaysSendAppInfoToProvider(true)
            .setOrigin(credentialBundle.getString("origin"))
            .build();

    final CredentialManager manager =
        (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE);
    if (manager == null) {
      return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
    }
    final GeckoResult<WebAuthnUtils.MakeCredentialResponse> result = new GeckoResult<>();
    try {
      manager.createCredential(
          context,
          request,
          null,
          context.getMainExecutor(),
          new OutcomeReceiver<CreateCredentialResponse, CreateCredentialException>() {
            @Override
            public void onResult(final CreateCredentialResponse createCredentialResponse) {
              final Bundle data = createCredentialResponse.getData();
              final String responseJson = data.getString(BUNDLE_KEY_REGISTRATION_RESPONSE_JSON);
              if (responseJson == null) {
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
                return;
              }
              if (DEBUG) {
                Log.d(LOGTAG, "Response JSON: " + responseJson);
              }
              try {
                result.complete(WebAuthnUtils.getMakeCredentialResponse(responseJson));
              } catch (final IllegalArgumentException e) {
                Log.e(LOGTAG, "Invalid response", e);
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
              } catch (final JSONException e) {
                Log.e(LOGTAG, "Couldn't parse response JSON", e);
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
              }
            }

            @Override
            public void onError(final CreateCredentialException exception) {
              final String errorType = exception.getType();
              if (DEBUG) {
                Log.d(LOGTAG, "Couldn't create credential. errorType=" + errorType);
              }
              if (errorType.equals(CreateCredentialException.TYPE_USER_CANCELED)) {
                result.completeExceptionally(new WebAuthnUtils.Exception("ABORT_ERR"));
                return;
              }
              if (errorType.equals(CreateCredentialException.TYPE_NO_CREATE_OPTIONS)) {
                result.completeExceptionally(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
                return;
              }
              result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
            }
          });
    } catch (final SecurityException e) {
      // We might be no permission for Credential Manager
      return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
    } catch (final java.lang.Exception e) {
      Log.w(LOGTAG, "Couldn't make credential", e);
      return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
    }
    return result;
  }

  @SuppressLint("MissingPermission")
  public static GeckoResult<PrepareGetCredentialResponse.PendingGetCredentialHandle>
      prepareGetAssertion(
          final byte[] challenge,
          final WebAuthnUtils.WebAuthnPublicCredential[] allowList,
          final GeckoBundle assertionBundle,
          final GeckoBundle extensions,
          final byte[] clientDataHash) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
      // No credential manager. Relay to GMS FIDO2
      return GeckoResult.fromValue(null);
    }
    final Bundle requestBundle =
        getRequestBundleForGetAssertion(
            challenge, allowList, assertionBundle, extensions, clientDataHash);
    if (requestBundle == null) {
      return GeckoResult.fromValue(null);
    }

    requestBundle.putBoolean(BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED, allowList.length > 0);

    final CredentialOption credentialOption =
        new CredentialOption.Builder(TYPE_PUBLIC_KEY_CREDENTIAL, requestBundle, requestBundle)
            .build();
    final Bundle bundle = new Bundle();
    final GetCredentialRequest request =
        new GetCredentialRequest.Builder(requestBundle)
            .addCredentialOption(credentialOption)
            .setAlwaysSendAppInfoToProvider(true)
            .setOrigin(assertionBundle.getString("origin"))
            .build();

    final Context context = GeckoAppShell.getApplicationContext();
    final CredentialManager manager =
        (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE);
    if (manager == null) {
      // No credential manager. Relay to GMS FIDO2
      return GeckoResult.fromValue(null);
    }

    final GeckoResult<PrepareGetCredentialResponse.PendingGetCredentialHandle> result =
        new GeckoResult<>();
    try {
      manager.prepareGetCredential(
          request,
          null,
          context.getMainExecutor(),
          new OutcomeReceiver<PrepareGetCredentialResponse, GetCredentialException>() {
            @Override
            public void onResult(final PrepareGetCredentialResponse prepareGetCredentialResponse) {
              final boolean hasPublicKeyCredentials =
                  prepareGetCredentialResponse.hasCredentialResults(TYPE_PUBLIC_KEY_CREDENTIAL);
              final boolean hasAuthenticationResults =
                  prepareGetCredentialResponse.hasAuthenticationResults();

              if (DEBUG) {
                Log.d(
                    LOGTAG,
                    "prepareGetCredential: hasPublicKeyCredentials="
                        + hasPublicKeyCredentials
                        + ", hasAuthenticationResults="
                        + hasAuthenticationResults);
              }
              if (!hasPublicKeyCredentials && !hasAuthenticationResults) {
                // No passkey and result. Relay to GMS FIDO2.
                result.complete(null);
                return;
              }

              result.complete(prepareGetCredentialResponse.getPendingGetCredentialHandle());
            }

            @Override
            public void onError(final GetCredentialException exception) {
              if (DEBUG) {
                final String errorType = exception.getType();
                Log.d(LOGTAG, "Couldn't get credential. errorType=" + errorType);
              }
              result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
            }
          });
    } catch (final SecurityException e) {
      // We might be no permission for Credential Manager. Use FIDO2 API
      return GeckoResult.fromValue(null);
    } catch (final java.lang.Exception e) {
      Log.e(LOGTAG, "prepareGetCredential throws an error", e);
      return GeckoResult.fromValue(null);
    }
    return result;
  }

  public static GeckoResult<WebAuthnUtils.GetAssertionResponse> getAssertion(
      final PrepareGetCredentialResponse.PendingGetCredentialHandle pendingHandle) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
      return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
    }
    final GeckoResult<WebAuthnUtils.GetAssertionResponse> result = new GeckoResult<>();
    final Context context = GeckoAppShell.getApplicationContext();
    final CredentialManager manager =
        (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE);
    if (manager == null) {
      return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
    }
    try {
      manager.getCredential(
          context,
          pendingHandle,
          null,
          context.getMainExecutor(),
          new OutcomeReceiver<GetCredentialResponse, GetCredentialException>() {
            @Override
            public void onResult(final GetCredentialResponse getCredentialResponse) {
              final Bundle data = getCredentialResponse.getCredential().getData();
              final String responseJson = data.getString(BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON);
              if (responseJson == null) {
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
                return;
              }
              if (DEBUG) {
                Log.d(LOGTAG, "Response JSON: " + responseJson);
              }
              try {
                result.complete(WebAuthnUtils.getGetAssertionResponse(responseJson));
              } catch (final IllegalArgumentException e) {
                Log.e(LOGTAG, "Invalid response", e);
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
              } catch (final JSONException e) {
                Log.e(LOGTAG, "Couldn't parse response JSON", e);
                result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR"));
              }
            }

            @Override
            public void onError(final GetCredentialException exception) {
              final String errorType = exception.getType();
              if (DEBUG) {
                Log.d(LOGTAG, "Couldn't get credential. errorType=" + errorType);
              }
              if (errorType.equals(GetCredentialException.TYPE_USER_CANCELED)) {
                result.completeExceptionally(new WebAuthnUtils.Exception("ABORT_ERR"));
                return;
              }
              result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
            }
          });
    } catch (final SecurityException e) {
      // We might be no permission for Credential Manager.
      return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR"));
    } catch (final java.lang.Exception e) {
      Log.w(LOGTAG, "Couldn't get assertion", e);
      return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR"));
    }
    return result;
  }
}
