package duo.labs.webauthn.util;

import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyInfo;
import android.security.keystore.KeyProperties;
import android.support.annotation.NonNull;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.UnrecoverableEntryException;
import java.security.cert.CertificateException;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECPoint;
import java.security.spec.InvalidKeySpecException;
import java.util.List;

import co.nstant.in.cbor.CborBuilder;
import co.nstant.in.cbor.CborEncoder;
import co.nstant.in.cbor.CborException;
import duo.labs.webauthn.exceptions.VirgilException;
import duo.labs.webauthn.models.PublicKeyCredentialSource;
import duo.labs.webauthn.util.database.CredentialDatabase;


/**
 * CredentialSafe uses the Android KeyStore to generate and store
 * ES256 keys that are hardware-backed.
 * <p>
 * These keys can optionally be protected with "Strongbox keymaster" protection and user
 * authentication on supported hardware.
 */
public class CredentialSafe {
    private static final String KEYSTORE_TYPE = "AndroidKeyStore";
    private static final String CURVE_NAME = "secp256r1";
    private KeyStore keyStore;
    private boolean authenticationRequired;
    private boolean strongboxRequired;
    private CredentialDatabase db;

    /**
     * Construct a CredentialSafe that requires user authentication and strongbox backing.
     *
     * @param ctx The application context
     * @throws VirgilException
     */
    public CredentialSafe(Context ctx) throws VirgilException {
        this(ctx, true, true);
    }

    /**
     * Construct a CredentialSafe with configurable user authentication / strongbox choices.
     *
     * @param ctx                    The application context
     * @param authenticationRequired Whether user will be required to use biometrics to allow each
     *                               use of keys generated (requires fingerprint enrollment).
     * @param strongboxRequired      Require keys to be backed by the "Strongbox Keymaster" HSM.
     *                               Requires hardware support.
     * @throws VirgilException
     */
    public CredentialSafe(Context ctx, boolean authenticationRequired, boolean strongboxRequired) throws VirgilException {
        try {
            keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
            keyStore.load(null);
        } catch (KeyStoreException | CertificateException |
                NoSuchAlgorithmException | IOException e) {
            throw new VirgilException("couldn't access keystore", e);
        }

        this.authenticationRequired = authenticationRequired;
        this.strongboxRequired = strongboxRequired;
        this.db = CredentialDatabase.getDatabase(ctx);
    }

    /**
     * Determine if user verification (by the WebAuthn definition) is supported.
     *
     * @return status of user verification requirement
     */
    public boolean supportsUserVerification() {
        return this.authenticationRequired;
    }


    /**
     * Generate a new ES256 keypair (COSE algorithm -7, ECDSA + SHA-256 over the NIST P-256 curve).
     *
     * @param alias The alias used to identify this keypair in the keystore. Needed to use key
     *              in the future.
     * @return The KeyPair object representing the newly generated keypair.
     * @throws VirgilException
     */
    private KeyPair generateNewES256KeyPair(String alias) throws VirgilException {
        KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
                .setAlgorithmParameterSpec(new ECGenParameterSpec(CURVE_NAME))
                .setDigests(KeyProperties.DIGEST_SHA256)
                .setUserAuthenticationRequired(this.authenticationRequired) // fingerprint or similar
                .setUserConfirmationRequired(false) // TODO: Decide if we support Android Trusted Confirmations
                .setInvalidatedByBiometricEnrollment(false)
                .setIsStrongBoxBacked(this.strongboxRequired)
                .build();
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_TYPE);
            keyPairGenerator.initialize(spec);
            KeyPair keyPair = keyPairGenerator.generateKeyPair();
            return keyPair;
        } catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) {
            throw new VirgilException("couldn't generate key pair: " + e.toString());
        }
    }

    /**
     * Generate and save new credential with an ES256 keypair.
     *
     * @param rpEntityId      The relying party's identifier
     * @param userHandle      A unique ID for the user
     * @param userDisplayName A human-readable username for the user
     * @return A PublicKeyCredentialSource object corresponding to the new keypair and its associated
     * rpId, credentialId, etc.
     * @throws VirgilException
     */
    public PublicKeyCredentialSource generateCredential(@NonNull String rpEntityId, byte[] userHandle, String userDisplayName) throws VirgilException {
        PublicKeyCredentialSource credentialSource = new PublicKeyCredentialSource(rpEntityId, userHandle, userDisplayName);
        generateNewES256KeyPair(credentialSource.keyPairAlias); // return not captured -- will retrieve credential by alias
        db.credentialDao().insert(credentialSource);
        return credentialSource;
    }

    public void deleteCredential(PublicKeyCredentialSource credentialSource) {
        db.credentialDao().delete(credentialSource);
    }


    /**
     * Get keys belonging to this RP ID.
     *
     * @param rpEntityId rpEntity.id from WebAuthn spec.
     * @return The set of associated PublicKeyCredentialSources.
     */
    public List<PublicKeyCredentialSource> getKeysForEntity(@NonNull String rpEntityId) {
        return db.credentialDao().getAllByRpId(rpEntityId);
    }

    /**
     * Get the credential matching the specified id, if it exists
     *
     * @param id byte[] credential id
     * @return PublicKeyCredentialSource that matches the id, or null
     */
    public PublicKeyCredentialSource getCredentialSourceById(@NonNull byte[] id) {
        return db.credentialDao().getById(id);
    }


    /**
     * Retrieve a previously-generated keypair from the keystore.
     *
     * @param alias The associated keypair alias.
     * @return A KeyPair object representing the public/private keys. Private key material is
     * not accessible.
     * @throws VirgilException
     */
    public KeyPair getKeyPairByAlias(@NonNull String alias) throws VirgilException {
        KeyStore.Entry keyEntry;
        try {
            PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null);
            PublicKey publicKey = keyStore.getCertificate(alias).getPublicKey();
            return new KeyPair(publicKey, privateKey);
        } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableEntryException e) {
            throw new VirgilException("couldn't get key by alias", e);
        }
    }

    /**
     * Checks whether this key requires user verification or not
     *
     * @param alias The associated keypair alias
     * @return whether this key requires user verification or not
     * @throws VirgilException
     */
    public boolean keyRequiresVerification(@NonNull String alias) throws VirgilException {
        PrivateKey privateKey = getKeyPairByAlias(alias).getPrivate();
        KeyFactory factory;
        KeyInfo keyInfo;

        try {
            factory = KeyFactory.getInstance(privateKey.getAlgorithm(), KEYSTORE_TYPE);
        } catch (NoSuchAlgorithmException | NoSuchProviderException exception) {
            throw new VirgilException("Couldn't build key factory: " + exception.toString());
        }

        try {
            keyInfo = factory.getKeySpec(privateKey, KeyInfo.class);
        } catch (InvalidKeySpecException exception) {
            throw new VirgilException("Not an android keystore key: " + exception.toString());
        }

        return keyInfo.isUserAuthenticationRequired();
    }


    /**
     * Fix the length of a byte array such that:
     * 1) If the desired length is less than the length of `arr`, the left-most source bytes are
     * truncated.
     * 2) If the desired length is more than the length of `arr`, the left-most destination bytes
     * are set to 0x00.
     *
     * @param arr         The source byte array.
     * @param fixedLength The desired length of the resulting array.
     * @return A new array of length fixedLength.
     */
    private static byte[] toUnsignedFixedLength(byte[] arr, int fixedLength) {
        byte[] fixed = new byte[fixedLength];
        int offset = fixedLength - arr.length;
        int srcPos = Math.max(-offset, 0);
        int dstPos = Math.max(offset, 0);
        int copyLength = Math.min(arr.length, fixedLength);
        System.arraycopy(arr, srcPos, fixed, dstPos, copyLength);
        return fixed;
    }

    /**
     * Encode an EC public key in the COSE/CBOR format.
     *
     * @param publicKey The public key.
     * @return A COSE_Key-encoded public key as byte array.
     * @throws VirgilException
     */
    public static byte[] coseEncodePublicKey(PublicKey publicKey) throws VirgilException {
        ECPublicKey ecPublicKey = (ECPublicKey) publicKey;
        ECPoint point = ecPublicKey.getW();
        // ECPoint coordinates are *unsigned* values that span the range [0, 2**32). The getAffine
        // methods return BigInteger objects, which are signed. toByteArray will output a byte array
        // containing the two's complement representation of the value, outputting only as many
        // bytes as necessary to do so. We want an unsigned byte array of length 32, but when we
        // call toByteArray, we could get:
        // 1) A 33-byte array, if the point's unsigned representation has a high 1 bit.
        //    toByteArray will prepend a zero byte to keep the value positive.
        // 2) A <32-byte array, if the point's unsigned representation has 9 or more high zero
        //    bits.
        // Due to this, we need to either chop off the high zero byte or prepend zero bytes
        // until we have a 32-length byte array.
        byte[] xVariableLength = point.getAffineX().toByteArray();
        byte[] yVariableLength = point.getAffineY().toByteArray();

        byte[] x = toUnsignedFixedLength(xVariableLength, 32);
        assert x.length == 32;
        byte[] y = toUnsignedFixedLength(yVariableLength, 32);
        assert y.length == 32;

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            new CborEncoder(baos).encode(new CborBuilder()
                    .addMap()
                    .put(1, 2)  // kty: EC2 key type
                    .put(3, -7) // alg: ES256 sig algorithm
                    .put(-1, 1) // crv: P-256 curve
                    .put(-2, x) // x-coord
                    .put(-3, y) // y-coord
                    .end()
                    .build()
            );
        } catch (CborException e) {
            throw new VirgilException("couldn't serialize to cbor", e);
        }
        return baos.toByteArray();
    }

    /**
     * Increment the credential use counter for this credential.
     *
     * @param credential The credential whose counter we want to increase.
     * @return The value of the counter before incrementing.
     */
    public int incrementCredentialUseCounter(PublicKeyCredentialSource credential) {
        return db.credentialDao().incrementUseCounter(credential);
    }
}