/*
 * Copyright 2022 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * Licensed 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.jboss.installer.validators;

import org.apache.commons.lang3.StringUtils;
import org.jboss.installer.core.LanguageUtils;
import org.jboss.installer.core.ValidationResult;

import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.cert.CertificateException;
import java.util.Arrays;
import java.util.Set;

public class KeystoreCredentialsValidator {

    private static final String PKCS12_AUTH_ERROR = "Given final block not properly padded";
    private static final String JCEKS_AUTH_ERROR = "Keystore was tampered with, or password was incorrect";
    private static final String JKS_AUTH_ERROR = "Password verification failed";
    private static final Set<String> authErrorMessages = Set.of(PKCS12_AUTH_ERROR, JCEKS_AUTH_ERROR, JKS_AUTH_ERROR,
            "keystore password was incorrect");
    private static final String IBM_PKCS12_TYPE_ERROR = "Keystore type is not PKCS12";
    private static final String[] PKCS12_TYPE_ERROR = {"DerInputStream.getLength(): lengthTag=", "toDerInputStream rejects tag type"};
    private static final String JCEKS_JKS_TYPE_ERROR = "Invalid keystore format";
    public static final String[] DEFAULT_SUPPORTED_FORMATS = {"JKS", "JCEKS", "PKCS12"};

    private static final int SUCCESS = 0;
    private static final int AUTHENTICATION_FAILURE = 1;
    private static final int NO_FILE = 2;
    private static final int JVM_NO_PROVIDER = 3;
    private static final int BAD_ENCODING = 4;
    private static final int URI_NOT_ABSOLUTE = 5;
    private static final int EMPTY_FILE = 6;
    private static final int NOT_SUPPORTED = 7;
    public static final String KEYSTORE_VALIDATOR_AUTHENTICATION_FAILURE = "validator.authentication.failure";
    public static final String KEYSTORE_VALIDATOR_FILE_DOES_NOT_EXIST = "validator.file.does.not.exist";
    public static final String KEYSTORE_VALIDATOR_JVM_CANNOT_READ = "validator.jvm.cannot.read";
    public static final String KEYSTORE_VALIDATOR_INVALID_URL = "validator.invalid.url";
    public static final String KEYSTORE_VALIDATOR_FILE_IS_EMPTY = "validator.file.is.empty";
    public static final String KEYSTORE_VALIDATOR_NOT_SUPPORTED = "validator.not.supported";
    public static final String KEYSTORE_VALIDATOR_NOT_EMPTY = "validator.not_empty";

    private final LanguageUtils langUtils;
    private final String prefix;
    private String[] supportedFormats;
    private final String resourceBundleKey;

    public KeystoreCredentialsValidator(LanguageUtils langUtils, String messagePrefixKey) {
        this.langUtils = langUtils;
        this.prefix = langUtils.getString(messagePrefixKey);
        this.supportedFormats = DEFAULT_SUPPORTED_FORMATS;
        this.resourceBundleKey = "keystore";
    }

    private KeystoreCredentialsValidator(LanguageUtils langUtils, String prefixKey, String resourceBundleKey, String[] supportedFormats) {
        this.langUtils = langUtils;
        this.prefix = prefixKey==null?null:langUtils.getString(prefixKey);
        this.resourceBundleKey = resourceBundleKey;
        this.supportedFormats = supportedFormats;
    }

    public static KeystoreCredentialsValidator credentialStoreValidator(LanguageUtils langUtils) {
        return new KeystoreCredentialsValidator(langUtils, null, "cred_store", new String[] {"JCEKS"});
    }

    public ValidationResult validate(String path, String password, Path targetFolder) {
        return validate(path, password, targetFolder, false);
    }

    public ValidationResult validate(String path, String password, Path targetFolder, boolean requireEmpty) {
        ValidationResult currentStatus = ValidationResult.ok();
        String keystoreLoc = path;
        if(keystoreLoc == null) {
            return ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_FILE_DOES_NOT_EXIST));
        }
        if (keystoreLoc.contains("${jboss.home.dir}")) {
            keystoreLoc = keystoreLoc.replace("${jboss.home.dir}", targetFolder.toString());
        }
        char[] pwd = password.toCharArray();
        int result = SUCCESS;

        boolean checkedJKS = false;
        if (!isJKSValid()) {
            result = isValidKeystore(keystoreLoc, pwd, new String[]{"JKS"});
            checkedJKS = true;
        }

        if (checkedJKS && result == SUCCESS) {
            result = NOT_SUPPORTED;
        } else {
            result = isValidKeystore(keystoreLoc, pwd, supportedFormats);
        }

        switch (result) {
            case AUTHENTICATION_FAILURE:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_AUTHENTICATION_FAILURE));
                break;
            case NO_FILE:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_FILE_DOES_NOT_EXIST));
                break;
            case JVM_NO_PROVIDER:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_JVM_CANNOT_READ));
                break;
            case BAD_ENCODING:
            case URI_NOT_ABSOLUTE:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_INVALID_URL));
                break;
            case EMPTY_FILE:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_FILE_IS_EMPTY));
                break;
            case NOT_SUPPORTED:
                currentStatus = ValidationResult.error(getMessage(KEYSTORE_VALIDATOR_NOT_SUPPORTED,
                        StringUtils.join(supportedFormats, ", ")));
        }

        if (currentStatus.getResult() != ValidationResult.Result.OK) {
            return currentStatus;
        }

        if (requireEmpty) {
            return isEmpty(keystoreLoc, pwd, supportedFormats)?ValidationResult.ok():ValidationResult.error(
                    getMessage(KEYSTORE_VALIDATOR_NOT_EMPTY));
        }

        return currentStatus;
    }

    private String getMessage(String key) {
        if (prefix == null) {
            return langUtils.getString(resourceBundleKey + "." + key);
        } else {
            return prefix + ": " + langUtils.getString(resourceBundleKey + "." + key);
        }
    }

    private String getMessage(String key, String... args) {
        if (prefix == null) {
            return langUtils.getString(resourceBundleKey + "." + key, args);
        } else {
            return prefix + ": " + langUtils.getString(resourceBundleKey + "." + key, args);
        }
    }

    private boolean isEmpty(String keystoreUrl, char[] pwd, String[] supportedFormats) {
        for (String algorithm : supportedFormats) {
            final KeyStore ks;
            try {
                ks = KeyStore.getInstance(algorithm);
                if (isValidAccessibleUrl(keystoreUrl)) {
                    ks.load(new URI(keystoreUrl).toURL().openStream(), pwd);
                } else {
                    try (FileInputStream inStream = new FileInputStream(keystoreUrl)) {
                        ks.load(inStream, pwd);
                    }
                }
                return !ks.aliases().hasMoreElements();
            } catch (KeyStoreException | IOException | URISyntaxException | NoSuchAlgorithmException |
                     CertificateException e) {
                // OK, ignore we checked this
            }
        }
        throw new RuntimeException("Unable to access keystore: " + keystoreUrl);
    }

    /**
     * This method checks that a given keystore<br/>
     * a) exists (file) or is accessible (remote)<br/>
     * b) the provided password is correct for the keystore<br/>
     * 0 : everything is fine <br/>
     * 1 : authentication failure<br/>
     * 2 : file doesn't exist / hostname not accessible <br/>
     * 3 : the JVM doesn't have a provider that can read the keystore <br/>
     * 4 : the given URL contains characters that require encoding and does not do this correctly <br/>
     * 5 : the given URI is not absolute <br/>
     * 6 : the file is empty
     * 7 : the keystore is not in a JBOSS supported format.
     *
     * @param keystoreUrl
     * @param pwd
     * @param supportedFormats
     * @return int status
     */
    int isValidKeystore(String keystoreUrl, char[] pwd, String[] supportedFormats) {
        int status = SUCCESS;

        if (!isValidReadableFile(keystoreUrl) && !isValidAccessibleUrl(keystoreUrl)) {
            return NO_FILE;
        }


        KeyStore ks;

        for (String algorithm : supportedFormats) {
            // WINDOWS-ROOT and WINDOWS-MY keystores will not error out on a ks.load() call. They are not supported by any
            // feature of note in the installer, so we skip them entirely
            if (!Security.getAlgorithms("KeyStore").contains(algorithm.toUpperCase())
                    || algorithm.equalsIgnoreCase("WINDOWS-ROOT")
                    || algorithm.equalsIgnoreCase("WINDOWS-MY")) {
                status = NOT_SUPPORTED;
                continue;
            }

            try {
                ks = KeyStore.getInstance(algorithm);
                if (isValidAccessibleUrl(keystoreUrl)) {
                    ks.load(new URI(keystoreUrl).toURL().openStream(), pwd);
                } else {
                    try (FileInputStream inStream = new FileInputStream(keystoreUrl)) {
                        ks.load(inStream, pwd);
                    }
                }

                status = SUCCESS;
                break;
            } catch (IllegalArgumentException iae) {
                /**
                 * assume that this exception indicates that the URI is not absolute. Indeed,
                 * docs: http://docs.oracle.com/javase/7/docs/api/java/net/URI.html
                 * indicate that URI.toURL() will throws this only in this case.
                 * currently, previous checks seem to make this impossible. still accounted for in usages though
                 *
                 * it is safe to short circuit here, since an invalid URI will never load successfully.
                 */
                return URI_NOT_ABSOLUTE;

            } catch (NoSuchAlgorithmException | CertificateException e) {
                // may occur if the user is using a JRE that doesn't include the format the user is trying to use
                status = JVM_NO_PROVIDER;
            } catch (FileNotFoundException e) {
                // If connection is legit, but file is not accessible/doesn't exist at remote location.
                status = NO_FILE;

            } catch (EOFException e) {
                status = EMPTY_FILE;
            } catch (IOException e) {
                /**
                 * This is thrown on incorrect passwords by most keystore algo  implementations.
                 * Its use is overloaded.
                 */
                String message = e.getMessage();
                /**
                 * First check if the IOException is the result of a type mismatch.
                 */
                if (message.equals(JCEKS_JKS_TYPE_ERROR) || message.equals(IBM_PKCS12_TYPE_ERROR)
                        || Arrays.stream(PKCS12_TYPE_ERROR).anyMatch(s->message.startsWith(s))) {
                    status = NOT_SUPPORTED;
                }
                /**
                 * It wasn't the keystore type; let's see if it's an Authentication problem
                 */
                else if (authErrorMessages.contains(message)) {
                    status = AUTHENTICATION_FAILURE;
                    return status; // short circuiting here is safe now, because of the authentication / type error differentiation additions
                } else {
                    // unknown IOException. Possible to just consider it a general Authentication problem.
                    status = JVM_NO_PROVIDER;
                }

            } catch (KeyStoreException e) {
                /**
                 * this shouldn't really happen ever
                 * if this is thrown, again, the JRE doesn't include a Provider that can provide a KeyStore instance of type "JKS", which
                 * means that either the JRE is very old or is non-standard in some critical way
                 */
                status = JVM_NO_PROVIDER;

            } catch (URISyntaxException e) {
                // some values weren't encoded on in the URI, so we give a better message to users
                status = BAD_ENCODING;
            }
        }
        return status;
    }

    /**
     * Determines if a given file exists, can be read, and isn't a directory
     *
     * @param file
     * @return
     */
    boolean isValidReadableFile(String file) {
        File check = new File(file);
        return check.exists() && check.canRead() && !check.isDirectory();
    }

    /**
     * Determines if a given URL is both valid and accessible
     *
     * @param url
     * @return
     */
    boolean isValidAccessibleUrl(String url) {
        URL check;
        try {
            check = new URL(url);
            URLConnection conn = check.openConnection();
            conn.connect();
        } catch (IOException e) {
            // bad URL or the connection couldn't be established
            return false;
        }
        return true;
    }

    private boolean isJKSValid() {
        for (int i = 0; i <= supportedFormats.length - 1; i++) {
            String format = supportedFormats[i];
            if (format.equalsIgnoreCase("JKS"))
                return true;
        }
        return false;
    }
}
