/*
 * Copyright (c) 2017-2019 block.one all rights reserved.
 */

package one.block.eosiojava.utilities;

import one.block.eosiojava.enums.AlgorithmEmployed;
import one.block.eosiojava.error.ErrorConstants;
import one.block.eosiojava.error.utilities.Base58ManipulationError;
import one.block.eosiojava.error.utilities.EOSFormatterError;
import one.block.eosiojava.error.utilities.PEMProcessorError;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DLSequence;
import org.bouncycastle.asn1.sec.SECObjectIdentifiers;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.asn1.x9.X9ECParameters;
import org.bouncycastle.crypto.ec.CustomNamedCurves;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.math.ec.ECPoint;
import org.bouncycastle.math.ec.FixedPointCombMultiplier;
import org.bouncycastle.math.ec.FixedPointUtil;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.encoders.Hex;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import org.jetbrains.annotations.NotNull;

import java.io.CharArrayReader;
import java.io.IOException;
import java.io.Reader;
import java.math.BigInteger;

/**
 * This is a wrapper class for PEMObjects that throws a {@link PEMProcessorError} if an invalid
 * PEMObject is passed into the constructor.  Once initialized the PEMProcessor can be used to
 * return the type, DER format, or algorithm used to create the PEMObject.
 */
public class PEMProcessor {

    /**
     * PEM private key type on header
     */
    private static final String PRIVATE_KEY_TYPE = "EC PRIVATE KEY";

    /**
     * Private key start index on ASN.1 sequence
     */
    private static final int PRIVATE_KEY_START_INDEX = 2;

    //region CURVE Constants
    /**
     * Constant name of secp256r1 curves
     */
    private static final String SECP256_R1 = "secp256r1";

    /**
     * Constant name of secp256k1 curves
     */
    private static final String SECP256_K1 = "secp256k1";

    /**
     * EC parameters holder of secp256r1 key type
     */
    private static final X9ECParameters CURVE_PARAMS_R1 = CustomNamedCurves.getByName(SECP256_R1);

    /**
     * EC parameters holder of secp256k1 key type
     */
    private static final X9ECParameters CURVE_PARAMS_K1 = CustomNamedCurves.getByName(SECP256_K1);

    /**
     * EC holder of secp256r1 key type
     */
    private static final ECDomainParameters CURVE_R1;

    /**
     * EC holder of secp256k1 key type
     */
    private static final ECDomainParameters CURVE_K1;

    /**
     * Signum to convert a negative value to a positive Big Integer
     */
    private static final int BIG_INTEGER_POSITIVE = 1;


    static {
        // secp256r1
        FixedPointUtil.precompute(CURVE_PARAMS_R1.getG());
        CURVE_R1 = new ECDomainParameters(
                CURVE_PARAMS_R1.getCurve(),
                CURVE_PARAMS_R1.getG(),
                CURVE_PARAMS_R1.getN(),
                CURVE_PARAMS_R1.getH());

        // secp256k1
        CURVE_K1 = new ECDomainParameters(
                CURVE_PARAMS_K1.getCurve(),
                CURVE_PARAMS_K1.getG(),
                CURVE_PARAMS_K1.getN(),
                CURVE_PARAMS_K1.getH());
    }
    //endregion

    private PemObject pemObject;
    private String pemObjectString;

    /**
     * Initialize PEMProcessor with PEM content in String format.
     *
     * @param pemObject - input PEM content in String format.
     * @throws PEMProcessorError When failing to read pem data from the input.
     */
    public PEMProcessor(String pemObject) throws PEMProcessorError {
        this.pemObjectString = pemObject;
        try (Reader reader = new CharArrayReader(this.pemObjectString.toCharArray());
                PemReader pemReader = new PemReader(reader)) {
            this.pemObject = pemReader.readPemObject();
            if (this.pemObject == null) {
                throw new PEMProcessorError(ErrorConstants.INVALID_PEM_OBJECT);
            }

        } catch (Exception e) {
            throw new PEMProcessorError(ErrorConstants.ERROR_PARSING_PEM_OBJECT, e);
        }

    }

    /**
     * Gets the PEM Object key type (i.e. PRIVATE KEY, PUBLIC KEY).
     *
     * @return key type as string
     */
    @NotNull
    public String getType() {
        return pemObject.getType();
    }

    /**
     * Gets the DER encoded format of the key from its PEM format.
     *
     * @return DER format of key as string
     */
    @NotNull
    public String getDERFormat() {
        return Hex.toHexString(pemObject.getContent());
    }

    /**
     * Gets the algorithm used to generate the key from its PEM format.
     *
     * @return The algorithm used to generate the key.
     * @throws PEMProcessorError if the algorithm fetch leads to an exception.
     */
    @NotNull
    public AlgorithmEmployed getAlgorithm() throws PEMProcessorError {

        Object pemObjectParsed = parsePEMObject();

        String oid;
        if (pemObjectParsed instanceof SubjectPublicKeyInfo) {
            oid = ((SubjectPublicKeyInfo) pemObjectParsed).getAlgorithm().getParameters()
                    .toString();
        } else if (pemObjectParsed instanceof PEMKeyPair) {
            oid = ((PEMKeyPair) pemObjectParsed).getPrivateKeyInfo().getPrivateKeyAlgorithm()
                    .getParameters().toString();
        } else {
            throw new PEMProcessorError(ErrorConstants.DER_TO_PEM_CONVERSION);
        }

        if (SECObjectIdentifiers.secp256r1.getId().equals(oid)) {
            return AlgorithmEmployed.SECP256R1;
        } else if (SECObjectIdentifiers.secp256k1.getId().equals(oid)) {
            return AlgorithmEmployed.SECP256K1;
        } else {
            throw new PEMProcessorError(ErrorConstants.UNSUPPORTED_ALGORITHM + oid);
        }
    }

    /**
     * Gets the key as a byte array from its PEM format.
     *
     * @return key as byte[]
     * @throws PEMProcessorError when key data is unobtainable.
     */
    @NotNull
    public byte[] getKeyData() throws PEMProcessorError {

        Object pemObjectParsed = parsePEMObject();

        if (pemObjectParsed instanceof SubjectPublicKeyInfo) {
            return ((SubjectPublicKeyInfo) pemObjectParsed).getPublicKeyData().getBytes();
        } else if (pemObjectParsed instanceof PEMKeyPair) {

            DLSequence sequence;
            try (ASN1InputStream asn1InputStream = new ASN1InputStream(
                    Hex.decode(this.getDERFormat()))) {
                sequence = (DLSequence) asn1InputStream.readObject();
            } catch (IOException e) {
                throw new PEMProcessorError(e);
            }
            for (Object obj : sequence) {
                if (obj instanceof DEROctetString) {
                    byte[] key = new byte[0];
                    try {
                        key = ((DEROctetString) obj).getEncoded();
                    } catch (IOException e) {
                        throw new PEMProcessorError(e);
                    }
                    return Arrays.copyOfRange(key, PRIVATE_KEY_START_INDEX, key.length);
                }
            }
            throw new PEMProcessorError(ErrorConstants.KEY_DATA_NOT_FOUND);

        } else {
            throw new PEMProcessorError(ErrorConstants.DER_TO_PEM_CONVERSION);
        }
    }

    /**
     * Extract EOS public key
     *
     * @param isLegacy - Set to true if the legacy format of the key is desired.  This uses "EOS"
     * to prefix the key data and only applies to keys generated with the secp256k1 algorithm.  The
     * new format prefixes the key data with "PUB_K1_".
     * @return EOS format public key of the current private key
     * @throws PEMProcessorError when the public key extraction fails.
     */
    public String extractEOSPublicKeyFromPrivateKey(boolean isLegacy) throws PEMProcessorError {
        if (!this.getType().equals(PRIVATE_KEY_TYPE)) {
            throw new PEMProcessorError(ErrorConstants.PUBLIC_KEY_COULD_NOT_BE_EXTRACTED_FROM_PRIVATE_KEY);
        }

        AlgorithmEmployed keyCurve = this.getAlgorithm();
        BigInteger privateKeyBI = new BigInteger(BIG_INTEGER_POSITIVE, this.getKeyData());
        BigInteger n;
        ECPoint g;

        switch (keyCurve) {
            case SECP256R1:
                n = CURVE_R1.getN();
                g = CURVE_R1.getG();
                break;

            default:
                n = CURVE_K1.getN();
                g = CURVE_K1.getG();
                break;
        }

        if (privateKeyBI.bitLength() > n.bitLength()) {
            privateKeyBI = privateKeyBI.mod(n);
        }

        byte[] publicKeyByteArray = new FixedPointCombMultiplier().multiply(g, privateKeyBI).getEncoded(true);

        try {
            return EOSFormatter.encodePublicKey(publicKeyByteArray, keyCurve, isLegacy);
        } catch (Base58ManipulationError e) {
            throw new PEMProcessorError(e);
        }
    }

    /**
     * Extract PEM public key
     *
     * @param isLegacy Whether to return the legacy format of the key.  This uses "EOS"
     * to prefix the key data and only applies to keys generated with the secp256k1 algorithm.  The
     * new format prefixes the key data with "PUB_K1_".
     * @return EOS format public key of the current private key
     * @throws PEMProcessorError when public key extraction fails.
     */
    public String extractPEMPublicKeyFromPrivateKey(boolean isLegacy) throws PEMProcessorError {
        try {
            return EOSFormatter.convertEOSPublicKeyToPEMFormat(extractEOSPublicKeyFromPrivateKey(isLegacy));
        } catch (EOSFormatterError e) {
            throw new PEMProcessorError(e);
        }
    }

    /**
     * Gets EC Curve's domain parameter by curve type
     *
     * @param curve - type
     * @return ECDomainParameters of input curve
     * @throws PEMProcessorError would be throw if input curve is not supported.
     */
    public static ECDomainParameters getCurveDomainParameters(AlgorithmEmployed curve) throws PEMProcessorError {
        switch (curve) {
            case SECP256R1:
            case PRIME256V1:
                return CURVE_R1;
            case SECP256K1:
                return CURVE_K1;
            default:
                throw new PEMProcessorError(ErrorConstants.UNSUPPORTED_ALGORITHM);
        }
    }

    /**
     * Parses PEM object.
     *
     * @return Parsed PEM object as Object.
     * @throws PEMProcessorError when PEM parsing fails.
     */
    @NotNull
    private Object parsePEMObject() throws PEMProcessorError {
        try (Reader reader = new CharArrayReader(this.pemObjectString.toCharArray());
                PEMParser pemParser = new PEMParser(reader)) {
            return pemParser.readObject();
        } catch (IOException e) {
            throw new PEMProcessorError(ErrorConstants.ERROR_READING_PEM_OBJECT, e);
        }
    }
}