/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.apache.sshd.certificate;

import java.security.KeyPair;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.sshd.common.BaseBuilder;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.config.keys.KeyUtils;
import org.apache.sshd.common.config.keys.OpenSshCertificate;
import org.apache.sshd.common.config.keys.OpenSshCertificate.CertificateOption;
import org.apache.sshd.common.config.keys.OpenSshCertificate.Type;
import org.apache.sshd.common.config.keys.OpenSshCertificateImpl;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
import org.apache.sshd.common.random.JceRandom;
import org.apache.sshd.common.signature.BuiltinSignatures;
import org.apache.sshd.common.signature.Signature;
import org.apache.sshd.common.signature.SignatureFactory;
import org.apache.sshd.common.util.MapEntryUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;

/**
 * Holds all the data necessary to create a signed OpenSSH Certificate
 */
public class OpenSshCertificateBuilder {

    // given a supported key type, map to the concrete OpenSSH Certificate type
    protected static final Map<String, String> SIGNATURE_ALGORITHM_MAP
            = MapEntryUtils.MapBuilder.<String, String> builder()
                    .put(KeyPairProvider.SSH_RSA, KeyPairProvider.SSH_RSA_CERT)
                    .put(KeyPairProvider.SSH_ED25519, KeyPairProvider.SSH_ED25519_CERT)
                    .put(KeyPairProvider.ECDSA_SHA2_NISTP256, KeyPairProvider.SSH_ECDSA_SHA2_NISTP256_CERT)
                    .put(KeyPairProvider.ECDSA_SHA2_NISTP384, KeyPairProvider.SSH_ECDSA_SHA2_NISTP384_CERT)
                    .put(KeyPairProvider.ECDSA_SHA2_NISTP521, KeyPairProvider.SSH_ECDSA_SHA2_NISTP521_CERT)
                    .build();

    protected final Type type;
    protected PublicKey publicKey;
    protected long serial;
    protected String id;
    protected Collection<String> principals;
    protected final Map<String, String> criticalOptions = new HashMap<>();
    protected final Map<String, String> extensions = new HashMap<>();
    // match ssh-keygen behavior where the default would be forever
    protected long validAfter = OpenSshCertificate.MIN_EPOCH;
    // match ssh-keygen behavior where the default would be forever
    protected long validBefore = OpenSshCertificate.INFINITY;
    protected byte[] nonce;

    protected OpenSshCertificateBuilder(Type type) {
        super();
        this.type = type;
    }

    public static OpenSshCertificateBuilder userCertificate() {
        return new OpenSshCertificateBuilder(Type.USER);
    }

    public static OpenSshCertificateBuilder hostCertificate() {
        return new OpenSshCertificateBuilder(Type.HOST);
    }

    public OpenSshCertificateBuilder publicKey(PublicKey publicKey) {
        this.publicKey = publicKey;
        return this;
    }

    public OpenSshCertificateBuilder serial(long serial) {
        this.serial = serial;
        return this;
    }

    public OpenSshCertificateBuilder id(String id) {
        this.id = id;
        return this;
    }

    public OpenSshCertificateBuilder principals(Collection<String> principals) {
        this.principals = principals;
        return this;
    }

    public OpenSshCertificateBuilder criticalOption(String name, String data) {
        String key = ValidateUtils.checkNotNullAndNotEmpty(name, "Critical option name must be set");
        this.criticalOptions.put(key, data == null ? "" : data);
        return this;
    }

    public OpenSshCertificateBuilder criticalOptions(List<OpenSshCertificate.CertificateOption> criticalOptions) {
        this.criticalOptions.clear();
        for (CertificateOption option : criticalOptions) {
            String key = ValidateUtils.checkNotNullAndNotEmpty(option.getName(), "Critical option name must be set");
            String value = option.getData();
            String prev = this.criticalOptions.put(key, value == null ? "" : value);
            if (prev != null) {
                throw new IllegalArgumentException("Duplicate certificate option " + key);
            }
        }
        return this;
    }

    public OpenSshCertificateBuilder criticalOptions(Map<String, String> criticalOptions) {
        this.criticalOptions.clear();
        criticalOptions.forEach((k, v) -> {
            String key = ValidateUtils.checkNotNullAndNotEmpty(k, "Critical option name must be set");
            this.criticalOptions.put(key, v == null ? "" : v);
        });
        return this;
    }

    public OpenSshCertificateBuilder extension(String name, String data) {
        String key = ValidateUtils.checkNotNullAndNotEmpty(name, "Extension name must be set");
        this.extensions.put(key, data == null ? "" : data);
        return this;
    }

    public OpenSshCertificateBuilder extensions(List<OpenSshCertificate.CertificateOption> extensions) {
        this.extensions.clear();
        for (CertificateOption option : extensions) {
            String key = ValidateUtils.checkNotNullAndNotEmpty(option.getName(), "Extension name must be set");
            String value = option.getData();
            String prev = this.extensions.put(key, value == null ? "" : value);
            if (prev != null) {
                throw new IllegalArgumentException("Duplicate certificate extension " + key);
            }
        }
        return this;
    }

    public OpenSshCertificateBuilder extensions(Map<String, String> extensions) {
        this.extensions.clear();
        extensions.forEach((k, v) -> {
            String key = ValidateUtils.checkNotNullAndNotEmpty(k, "Extension name must be set");
            this.extensions.put(key, v == null ? "" : v);
        });
        return this;
    }

    public OpenSshCertificateBuilder validAfter(long validAfter) {
        this.validAfter = validAfter;
        return this;
    }

    public OpenSshCertificateBuilder nonce(byte[] nonce) {
        this.nonce = nonce;
        return this;
    }

    /**
     * If null, uses {@link OpenSshCertificate#MIN_EPOCH}
     *
     * @param  validAfter {@link Instant} to use for validBefore
     * @return            Self reference
     */
    public OpenSshCertificateBuilder validAfter(Instant validAfter) {
        if (validAfter == null) {
            return validAfter(OpenSshCertificate.MIN_EPOCH);
        } else if (Instant.EPOCH.compareTo(validAfter) <= 0) {
            return validAfter(validAfter.getEpochSecond());
        }
        throw new IllegalArgumentException("Valid-after cannot be < epoch");
    }

    public OpenSshCertificateBuilder validBefore(long validBefore) {
        this.validBefore = validBefore;
        return this;
    }

    /**
     * If null, uses {@link OpenSshCertificate#INFINITY}
     *
     * @param  validBefore {@link Instant} to use for validBefore
     * @return             Self reference
     */
    public OpenSshCertificateBuilder validBefore(Instant validBefore) {
        if (validBefore == null) {
            return validBefore(OpenSshCertificate.INFINITY);
        } else if (Instant.EPOCH.compareTo(validBefore) <= 0) {
            return validBefore(validBefore.getEpochSecond());
        }
        throw new IllegalArgumentException("Valid-before cannot be < epoch");
    }

    protected void validate() {
        // nonce should be 16 or 32 bytes according to
        // https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys#L151-L153
        if (nonce != null && (nonce.length != 16 && nonce.length != 32)) {
            throw new IllegalStateException("'nonce' must be 16 or 32 bytes");
        }
        if (type == null) {
            throw new IllegalStateException("'type' is required");
        }
        if (id == null) {
            throw new IllegalStateException("'id' is required");
        }
        if (publicKey == null) {
            throw new IllegalStateException("'publicKey' is required");
        }
    }

    /**
     * Creates a certificate signed with the given CA key. For RSA keys "rsa-sha2-512" is used for the signature.
     *
     * @param  caKeypair CA key used to sign
     * @return           the signed certificate
     * @throws Exception if an error occurred
     */
    public OpenSshCertificate sign(KeyPair caKeypair) throws Exception {
        return sign(caKeypair, null);
    }

    /**
     * Creates a certificate signed with the given CA key using the specified signature algorithm. If a signature
     * algorithm is given, it must be appropriate for the CA key type, otherwise an exception is thrown. If
     * {@code signatureAlgorithm == null}, an appropriate signature algorithm is chosen automatically, for RSA keys
     * "rsa-sha2-512" is used then.
     *
     * @param  caKeypair          CA key used to sign
     * @param  signatureAlgorithm to use; if {@code null} automatically chosen based on the CA key type
     * @return                    the signed certificate
     * @throws Exception          if an error occurred
     */
    public OpenSshCertificate sign(KeyPair caKeypair, String signatureAlgorithm) throws Exception {
        validate();

        final String publicKeyType = KeyUtils.getKeyType(publicKey);
        final String certType = SIGNATURE_ALGORITHM_MAP.get(publicKeyType);

        // only certain kind of keys can be OpenSSH Certificates
        if (certType == null) {
            throw new UnsupportedOperationException(
                    "unsupported public key type '" + publicKeyType + "' for OpenSSH Certificate");
        }

        final OpenSshCertificateImpl cert = new OpenSshCertificateImpl();
        cert.setKeyType(certType);
        cert.setType(type);
        cert.setCertPubKey(publicKey);
        cert.setSerial(serial);
        cert.setId(id);
        if (principals != null && !principals.isEmpty()) {
            cert.setPrincipals(new ArrayList<>(principals));
        }
        if (!criticalOptions.isEmpty()) {
            cert.setCriticalOptions(criticalOptions);
        }
        if (!extensions.isEmpty()) {
            cert.setExtensions(extensions);
        }
        cert.setValidAfter(validAfter);
        cert.setValidBefore(validBefore);

        cert.setCaPubKey(caKeypair.getPublic());

        if (nonce != null) {
            cert.setNonce(nonce);
        } else {
            SecureRandom rand = JceRandom.getGlobalInstance();
            byte[] tempNonce = new byte[32];
            rand.nextBytes(tempNonce);
            cert.setNonce(tempNonce);
        }

        String algo = KeyUtils.getKeyType(caKeypair.getPublic());
        NamedFactory<? extends Signature> factory;
        if (signatureAlgorithm != null) {
            ValidateUtils.checkTrue(KeyUtils.getAllEquivalentKeyTypes(algo).contains(signatureAlgorithm),
                    "Invalid CA signature algorithm %s for CA key type %s", signatureAlgorithm, algo);
            algo = signatureAlgorithm;
            factory = BuiltinSignatures.fromFactoryName(algo);
        } else {
            factory = SignatureFactory.resolveSignatureFactory(algo, BaseBuilder.DEFAULT_SIGNATURE_PREFERENCE);
        }
        Signature signer = factory == null ? null : factory.create();
        ValidateUtils.checkNotNull(signer, "No signer could be located for signature algorithm=%s", algo);

        final ByteArrayBuffer toBeSignedBuf = new ByteArrayBuffer();
        toBeSignedBuf.putRawPublicKey(cert);

        final byte[] toSign = toBeSignedBuf.getCompactData();
        signer.initSigner(null, caKeypair.getPrivate());
        signer.update(null, toSign);

        final ByteArrayBuffer tmpBuffer = new ByteArrayBuffer();
        tmpBuffer.putString(factory.getName());
        tmpBuffer.putBytes(signer.sign(null));

        cert.setMessage(toSign);
        cert.setSignature(tmpBuffer.getCompactData());

        return cert;
    }
}
