/***** BEGIN LICENSE BLOCK *****
 * Version: EPL 1.0/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Eclipse Public
 * License Version 1.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.eclipse.org/legal/epl-v10.html
 *
 * Software distributed under the License is distributed on an "AS
 * IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * rights and limitations under the License.
 *
 * Copyright (C) 2006 Ola Bini <ola@ologix.com>
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either of the GNU General Public License Version 2 or later (the "GPL"),
 * or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the EPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the EPL, the GPL or the LGPL.
 ***** END LICENSE BLOCK *****/
package org.jruby.ext.openssl.x509store;

import org.jruby.ext.openssl.OpenSSL;
import org.jruby.ext.openssl.util.Cache;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_CERT_DIR;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_FILETYPE_ASN1;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_FILETYPE_DEFAULT;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_FILETYPE_PEM;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_LU_CRL;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_LU_FAIL;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_LU_X509;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_L_ADD_DIR;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_L_FILE_LOAD;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_R_BAD_X509_FILETYPE;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_R_INVALID_DIRECTORY;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_R_LOADING_CERT_DIR;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_R_LOADING_DEFAULTS;
import static org.jruby.ext.openssl.x509store.X509Utils.X509_R_WRONG_LOOKUP_TYPE;
import static org.jruby.ext.openssl.x509store.X509Utils.getDefaultCertificateDirectoryEnvironment;
import static org.jruby.ext.openssl.x509store.X509Utils.getDefaultCertificateFileEnvironment;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.cert.CRL;
import java.security.cert.CertificateException;
import java.security.cert.PKIXParameters;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;

import org.jruby.Ruby;
import org.jruby.RubyHash;
import org.jruby.ext.openssl.SecurityHelper;
import org.jruby.util.JRubyFile;
import org.jruby.util.SafePropertyAccessor;
import org.jruby.util.io.ChannelDescriptor;
import org.jruby.util.io.ChannelStream;
import org.jruby.util.io.FileExistsException;
import org.jruby.util.io.InvalidValueException;
import org.jruby.util.io.ModeFlags;

/**
 * X509_LOOKUP
 *
 * @author <a href="mailto:ola.bini@ki.se">Ola Bini</a>
 */
public class Lookup {

    boolean init = false;
    boolean skip = false;

    final LookupMethod method;
    private final Ruby runtime;

    Object methodData;
    Store store;

    /**
     * c: X509_LOOKUP_new
     */
    public Lookup(Ruby runtime, LookupMethod method) {
        if ( method == null ) {
            throw new IllegalArgumentException("null method");
        }
        this.method = method;
        this.runtime = runtime;

        final LookupMethod.NewItemFunction newItem = method.newItem;
        if ( newItem != null && newItem != Function1.EMPTY ) {
            final int result;
            try {
                result = newItem.call(this);
            }
            catch (Exception e) {
                if ( e instanceof RuntimeException ) throw (RuntimeException) e;
                throw new IllegalArgumentException("invalid lookup method", e);
            }
            if ( result == 0) throw new IllegalArgumentException("invalid lookup method");
        }
    }

    /**
     * c: X509_LOOKUP_load_file
     */
    public int loadFile(CertificateFile.Path file) throws Exception {
        return control(X509_L_FILE_LOAD, file.name, file.type, null);
    }

    /**
     * c: X509_LOOKUP_add_dir
     */
    public int addDir(CertificateHashDir.Dir dir) throws Exception {
        return control(X509_L_ADD_DIR, dir.name, dir.type, null);
    }

    /**
     * c: X509_LOOKUP_hash_dir
     */
    public static LookupMethod hashDirLookup() {
        return x509DirectoryLookup;
    }

    /**
     * c: X509_LOOKUP_file
     */
    public static LookupMethod fileLookup() {
        return x509FileLookup;
    }

    /**
     * c: X509_LOOKUP_ctrl
     */
    public int control(final int cmd, final String argc, final long argl, final String[] ret) throws Exception {
        if ( method == null ) return -1;

        if ( method.control != null && method.control != Function5.EMPTY ) {
            return method.control.call(this, Integer.valueOf(cmd), argc, Long.valueOf(argl), ret);
        }
        return 1;
    }

    private static final Cache<String, Object[]> certCache;

    static {
        // jruby.openssl.x509.cert.cache = true / false / 8
        final String cache = SafePropertyAccessor.getProperty("jruby.openssl.x509.lookup.cache");
        Cache<String, Object[]> certCacheInstance = null;
        if ( cache != null ) {
            try {
                certCacheInstance = Cache.newStrongSoftCache( Integer.parseInt(cache) );
            }
            catch (NumberFormatException ex) {
                if ( Boolean.parseBoolean(cache) ) {
                    certCacheInstance = Cache.newSoftCache();
                }
            }
        }
        if ( certCacheInstance == null ) certCacheInstance = Cache.getNullCache();
        certCache = certCacheInstance;
    }

    /**
     * c: X509_LOOKUP_load_cert_file
     */
    public int loadCertificateFile(final String file, final int type) throws IOException, CertificateException {
        if ( file == null ) return 1;

        final Object[] cached = certCache.get(file);

        BufferedReader reader = null;
        try {
            X509AuxCertificate auxCert;
            if ( type == X509_FILETYPE_PEM ) {
                int count = 0;
                if ( cached != null ) {
                    boolean storeError = false; for ( int c = 0; c < cached.length; c++ ) {
                        auxCert = buildAuxFromCached((X509Certificate) cached[c]);

                        if ( ! storeError ) {
                            if ( store.addCertificate(auxCert) != 0 ) count++;
                            else { storeError = true; count = 0; } // return 0
                        }
                    }
                }
                else {
                    reader = new BufferedReader(new InputStreamReader(wrapJRubyNormalizedInputStream(file)));
                    final ArrayList<Object> cacheEntry = new ArrayList<Object>(8);

                    boolean storeError = false; for (;;) {
                        auxCert = PEMInputOutput.readX509Aux(reader, null);
                        if ( auxCert == null ) break;

                        cacheEntry.add( auxCert.cloneForCache() ); // make sure we cache aux

                        if ( ! storeError ) {
                            if ( store.addCertificate(auxCert) != 0 ) count++;
                            else { storeError = true; count = 0; } // return 0
                        }
                    }

                    certCache.put(file, cacheEntry.toArray( new Object[ cacheEntry.size() ] ));
                }
                return count;
            }
            else if ( type == X509_FILETYPE_ASN1 ) {
                final X509Certificate cert;
                if ( cached != null ) {
                    cert = (X509Certificate) cached[0];
                    auxCert = buildAuxFromCached(cert);
                }
                else {
                    InputStream in = wrapJRubyNormalizedInputStream(file);
                    cert = (X509Certificate)
                        SecurityHelper.getCertificateFactory("X.509").generateCertificate(in);

                    auxCert = new X509AuxCertificate(cert);
                    certCache.put(file, new Object[] { auxCert.cloneForCache() });
                }
                //if ( auxCert == null ) {
                //    X509Error.addError(13); return 0;
                //}
                return store.addCertificate(auxCert);
            }
            else {
                X509Error.addError(X509_R_BAD_X509_FILETYPE);
                return 0; // NOTE: really?
            }
        }
        finally {
            if ( reader != null ) {
                try { reader.close(); } catch (Exception ignored) {}
            }
        }
    }

    private static X509AuxCertificate buildAuxFromCached(final X509Certificate cached) {
        X509AuxCertificate auxCert = StoreContext.ensureAux(cached);
        if ( cached == auxCert ) auxCert = auxCert.clone();
        return auxCert;
    }

    /**
     * c: X509_LOOKUP_load_crl_file
     */
    public int loadCRLFile(final String file, final int type) throws Exception {
        if ( file == null ) return 1;

        BufferedReader reader = null;
        try {
            InputStream in = wrapJRubyNormalizedInputStream(file);
            CRL crl;
            if ( type == X509_FILETYPE_PEM ) {
                reader = new BufferedReader(new InputStreamReader(in));
                int count = 0; for (;;) {
                    crl = PEMInputOutput.readX509CRL(reader, null);
                    if ( crl == null ) break;
                    if ( store.addCRL(crl) == 0 ) return 0;
                    count++;
                }
                return count;
            }
            else if ( type == X509_FILETYPE_ASN1 ) {
                crl = SecurityHelper.getCertificateFactory("X.509").generateCRL(in);
                //if ( crl == null ) {
                //    X509Error.addError(13); return 0;
                //}
                return store.addCRL(crl);
            }
            else {
                X509Error.addError(X509_R_BAD_X509_FILETYPE);
                return 0; // NOTE: really?
            }
        }
        finally {
            if ( reader != null ) {
                try { reader.close(); } catch (Exception ignored) {}
            }
        }
    }

    /**
     * c: X509_LOOKUP_load_cert_crl_file
     */
    public int loadCertificateOrCRLFile(final String file, final int type) throws IOException, CertificateException {
        if ( type != X509_FILETYPE_PEM ) return loadCertificateFile(file, type);

        final Object[] cached = certCache.get(file);

        BufferedReader reader = null;
        try {
            int count = 0;
            if ( cached != null ) {
                for ( int c = 0; c < cached.length; c++ ) {
                    Object cert = cached[c];
                    if ( cert instanceof X509Certificate ) {
                        store.addCertificate(buildAuxFromCached((X509Certificate) cert));
                        count++;
                    }
                    else if ( cert instanceof CRL ) {
                        store.addCRL((CRL) cert);
                        count++;
                    }
                }
            }
            else {
                reader = new BufferedReader(new InputStreamReader(wrapJRubyNormalizedInputStream(file)));
                final ArrayList<Object> cacheEntry = new ArrayList<Object>(8);
                for (;;) {
                    Object cert = PEMInputOutput.readPEM(reader, null);
                    if ( cert == null ) break;

                    if ( cert instanceof X509Certificate ) {
                        X509AuxCertificate auxCert = StoreContext.ensureAux((X509Certificate) cert);
                        store.addCertificate(auxCert);
                        count++;

                        cert = auxCert.cloneForCache(); // make sure we cache aux
                    }
                    else if ( cert instanceof CRL ) {
                        store.addCRL((CRL) cert);
                        count++;
                    }

                    cacheEntry.add(cert);
                }

                certCache.put(file, cacheEntry.toArray( new Object[ cacheEntry.size() ] ));
            }
            return count;
        }
        finally {
            if ( reader != null ) {
                try { reader.close(); } catch (Exception ignored) {}
            }
        }
    }

    public int loadDefaultJavaCACertsFile(String certsFile) throws IOException, GeneralSecurityException {
        final FileInputStream fin = new FileInputStream(certsFile);
        int count = 0;
        try {
	        // hardcode the keystore type, as we expect cacerts to be a java
	        // keystore - especially needed for jdk9
            KeyStore keystore = SecurityHelper.getKeyStore("jks");
	        // null password - as the cacerts file isn't password protected
            keystore.load(fin, null);
            PKIXParameters params = new PKIXParameters(keystore);
            for ( TrustAnchor trustAnchor : params.getTrustAnchors() ) {
                X509Certificate certificate = trustAnchor.getTrustedCert();
                store.addCertificate(certificate);
                count++;
            }
        }
        finally {
            try { fin.close(); } catch (Exception ignored) {}
        }
        return count;
    }

    private InputStream wrapJRubyNormalizedInputStream(String file) throws IOException {
        try {
            return JRubyFile.createResource(runtime, file).inputStream();
        }
        catch (NoSuchMethodError e) { // JRubyFile.createResource.inputStream (JRuby < 1.7.17)
            try {
                ChannelDescriptor descriptor = ChannelDescriptor.open(runtime.getCurrentDirectory(), file, new ModeFlags(ModeFlags.RDONLY));
                return ChannelStream.open(runtime, descriptor).newInputStream();
            }
            catch (NoSuchMethodError ex) {
                File f = new File(file);
                if ( ! f.isAbsolute() ) {
                    f = new File(runtime.getCurrentDirectory(), file);
                }
                return new BufferedInputStream(new FileInputStream(f));
            }
            catch (FileExistsException ex) {
                // should not happen because ModeFlag does not contain CREAT.
                OpenSSL.debugStackTrace(ex);
                throw new IllegalStateException(ex);
            }
            catch (InvalidValueException ex) {
                // should not happen because ModeFlasg does not contain APPEND.
                OpenSSL.debugStackTrace(ex);
                throw new IllegalStateException(ex);
            }
        }
    }

    private String envEntry(final String key) {
    	RubyHash env = (RubyHash) runtime.getObject().getConstant("ENV");
        return (String) env.get( runtime.newString(key) );
    }

    /**
     * c: X509_LOOKUP_free
     */
    public void free() throws Exception {
        if ( method != null && method.free != null && method.free != Function1.EMPTY ) {
            method.free.call(this);
        }
    }

    /**
     * c: X509_LOOKUP_init
     */
    public int init() throws Exception {
        if ( method == null ) return 0;
        if ( method.init != null && method.init != Function1.EMPTY ) {
            return method.init.call(this);
        }
        return 1;
    }

    /**
     * c: X509_LOOKUP_by_subject
     */
    public int bySubject(final int type, final Name name, final X509Object[] ret) throws Exception {
        if ( method == null || method.getBySubject == null || method.getBySubject == Function4.EMPTY ) {
            return X509_LU_FAIL;
        }
        if ( skip ) return 0;
        return method.getBySubject.call(this, Integer.valueOf(type), name, ret);
    }

    /**
     * c: X509_LOOKUP_by_issuer_serial
     */
    public int byIssuerSerialNumber(final int type, final Name name, final BigInteger serial, final X509Object[] ret) throws Exception {
        if ( method == null || method.getByIssuerSerialNumber == null || method.getByIssuerSerialNumber == Function5.EMPTY ) {
            return X509_LU_FAIL;
        }
        return method.getByIssuerSerialNumber.call(this, Integer.valueOf(type), name, serial, ret);
    }

    /**
     * c: X509_LOOKUP_by_fingerprint
     */
    public int byFingerprint(final int type, final String bytes, final X509Object[] ret) throws Exception {
        if ( method == null || method.getByFingerprint == null || method.getByFingerprint == Function4.EMPTY ) {
            return X509_LU_FAIL;
        }
        return method.getByFingerprint.call(this, Integer.valueOf(type), bytes, ret);
    }

    /**
     * c: X509_LOOKUP_by_alias
     */
    public int byAlias(final int type, final String alias, final X509Object[] ret) throws Exception {
        if ( method == null || method.getByAlias == null || method.getByAlias == Function4.EMPTY ) {
            return X509_LU_FAIL;
        }
        return method.getByAlias.call(this, Integer.valueOf(type), alias, ret);
    }

    /**
     * c: X509_LOOKUP_shutdown
     */
    public int shutdown() throws Exception {
        if ( method == null ) return 0;

        if ( method.shutdown != null && method.shutdown != Function1.EMPTY ) {
            return method.shutdown.call(this);
        }
        return 1;
    }

    /**
     * c: x509_file_lookup
     */
    private final static LookupMethod x509FileLookup = new LookupMethod();
    static {
        x509FileLookup.name = "Load file into cache";
        x509FileLookup.control = new ByFile();
    }

    /**
     * c: x509_dir_lookup
     */
    private final static LookupMethod x509DirectoryLookup = new LookupMethod();
    static {
        x509DirectoryLookup.name = "Load certs from files in a directory";
        x509DirectoryLookup.newItem = new NewLookupDir();
        x509DirectoryLookup.free = new FreeLookupDir();
        x509DirectoryLookup.control = new LookupDirControl();
        x509DirectoryLookup.getBySubject = new GetCertificateBySubject();
    }

    /**
     * c: by_file_ctrl
     */
    private static class ByFile implements LookupMethod.ControlFunction {
        public int call(final Lookup ctx, final Integer cmd, final String argp, final Number argl, String[] ret) throws Exception {
            int ok = 0;
            String file = null;
            final int arglInt = argl.intValue();

            switch(cmd) {
            case X509_L_FILE_LOAD:
                if (arglInt == X509_FILETYPE_DEFAULT) {
                    try {
                        file = ctx.envEntry( getDefaultCertificateFileEnvironment() ); // ENV['SSL_CERT_FILE']
                    }
                    catch (RuntimeException e) {
                        OpenSSL.debug(ctx.runtime, "failed to read SSL_CERT_FILE", e);
                    }
                    if (file == null) {
                        file = X509Utils.X509_CERT_FILE.replace('/', File.separatorChar);
                    }
                    if (file.matches(".*\\.(crt|cer|pem)$")) {
                        ok = ctx.loadCertificateOrCRLFile(file, X509_FILETYPE_PEM) != 0 ? 1 : 0;
                    } else {
                        ok = (ctx.loadDefaultJavaCACertsFile(file) != 0) ? 1: 0;
                    }
                    // we ignore errors on loading default paths the same way MRI does it
                } else {
                    if (arglInt == X509_FILETYPE_PEM) {
                        ok = (ctx.loadCertificateOrCRLFile(argp, X509_FILETYPE_PEM) != 0) ? 1 : 0;
                    } else {
                        ok = (ctx.loadCertificateFile(argp, arglInt) != 0) ? 1 : 0;
                    }
                }
                break;
            }

            return ok;
        }
    }

    /**
     * c: BY_DIR, lookup_dir_st
     */
    private static class LookupDir {
        Collection<String> dirs;
        Collection<Integer> dirsType;
    }

    /**
     * c: new_dir
     */
    private static class NewLookupDir implements LookupMethod.NewItemFunction {
        public int call(final Lookup lookup) {
            final LookupDir lookupDir = new LookupDir();
            lookupDir.dirs = new ArrayList<String>();
            lookupDir.dirsType = new ArrayList<Integer>();
            lookup.methodData = lookupDir;
            return 1;
        }
    }

    /**
     * c: free_dir
     */
    private static class FreeLookupDir implements LookupMethod.FreeFunction {
        public int call(final Lookup lookup) {
            final LookupDir lookupDir = (LookupDir) lookup.methodData;
            lookupDir.dirs = null;
            lookupDir.dirsType = null;
            lookup.methodData = null;
            return -1;
        }
    }

    /**
     * c: dir_ctrl
     */
    private static class LookupDirControl implements LookupMethod.ControlFunction {

        public int call(final Lookup ctx, final Integer cmd, String argp, Number argl, String[] retp) {
            int ret = 0;
            final LookupDir lookupData = (LookupDir) ctx.methodData;
            switch ( cmd ) {
            case X509_L_ADD_DIR :
                if ( argl.intValue() == X509_FILETYPE_DEFAULT ) {
                    String certDir = null;
                    try {
                        certDir = getDefaultCertificateDirectory(ctx);
                    }
                    catch (RuntimeException e) { }

                    if ( certDir != null ) {
                        ret = addCertificateDirectory(lookupData, certDir, X509_FILETYPE_PEM);
                    } else {
                        ret = addCertificateDirectory(lookupData, X509_CERT_DIR, X509_FILETYPE_PEM);
                    }
                    if ( ret == 0 ) {
                        X509Error.addError(X509_R_LOADING_CERT_DIR);
                    }
                }
                else {
                    ret = addCertificateDirectory(lookupData, argp, argl.intValue());
                }
                break;
            }
            return ret;
        }

        private static String getDefaultCertificateDirectory(final Lookup ctx) {
        	return ctx.envEntry( getDefaultCertificateDirectoryEnvironment() );
        }

        /**
         * c: add_cert_dir
         */
        private int addCertificateDirectory(final LookupDir ctx, final String dir, final int type) {
            if ( dir == null || dir.isEmpty() ) {
                X509Error.addError(X509_R_INVALID_DIRECTORY);
                return 0;
            }

            String[] dirs = dir.split(File.pathSeparator);

            for ( int i=0; i<dirs.length; i++ ) {
                if ( dirs[i].length() == 0 ) {
                    continue;
                }
                if ( ctx.dirs.contains(dirs[i]) ) {
                    continue;
                }
                ctx.dirsType.add(type);
                ctx.dirs.add(dirs[i]);
            }

            return 1;
        }
    }

    /**
     * c: get_cert_by_subject
     */
    private static class GetCertificateBySubject implements LookupMethod.BySubjectFunction {
        public int call(final Lookup lookup, final Integer type, final Name name, final X509Object[] ret) throws Exception {
            if ( name == null ) return 0;

            int ok = 0;

            final String postfix;
            if ( type == X509_LU_X509 ) {
                postfix = "";
            }
            else if ( type == X509_LU_CRL ) {
                postfix = "r";
            }
            else {
                X509Error.addError(X509_R_WRONG_LOOKUP_TYPE);
                return ok;
            }

            final LookupDir context = (LookupDir) lookup.methodData;

            final String hash = String.format("%08x", name.hash());
            final StringBuilder buffer = new StringBuilder(48);

            final Iterator<Integer> iter = context.dirsType.iterator();

            for ( final String dir : context.dirs ) {
                final int dirType = iter.next();
                for ( int k = 0; ; k++ ) {
                    buffer.setLength(0); // reset - clear buffer
                    buffer.append(dir).append(File.separatorChar);
                    buffer.append(hash);
                    buffer.append('.').append(postfix).append(k);

                    final String path = buffer.toString();
                    if ( ! new File(path).exists() ) break;

                    if ( type == X509_LU_X509 ) {
                        if ( lookup.loadCertificateFile(path, dirType) == 0 ) {
                            break;
                        }
                    } else if ( type == X509_LU_CRL ) {
                        if ( lookup.loadCRLFile(path, dirType) == 0 ) {
                            break;
                        }
                    }
                }
                X509Object tmp = null;
                for ( X509Object obj : lookup.store.getObjects() ) {
                    if ( obj.type() == type && obj.isName(name) ) {
                        tmp = obj; break;
                    }
                }
                if ( tmp != null ) {
                    ok = 1; ret[0] = tmp; break;
                }
            }

            return ok;
        }
    }

}// X509_LOOKUP
