/*
 * Copyright (c) 2016 Network New Technologies Inc.
 *
 * 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 com.networknt.server;

import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.core.joran.spi.JoranException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.client.Http2Client;
import com.networknt.config.Config;
import com.networknt.utility.TlsUtil;
import io.undertow.UndertowOptions;
import io.undertow.client.ClientConnection;
import io.undertow.client.ClientRequest;
import io.undertow.client.ClientResponse;
import io.undertow.util.Headers;
import io.undertow.util.Methods;
import org.owasp.encoder.Encode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xnio.IoUtils;
import org.xnio.OptionMap;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;

import javax.net.ssl.*;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import static com.networknt.server.Server.ENV_PROPERTY_KEY;
import static com.networknt.server.Server.STARTUP_CONFIG_NAME;


/**
 * Default Config Loader to fetch and load configs from light config server
 *
 * @author [email protected]
 *
 */
public class DefaultConfigLoader implements IConfigLoader{
    static final Logger logger = LoggerFactory.getLogger(DefaultConfigLoader.class);

    public static Map<String, Object> startupConfig = Config.getInstance().getJsonMapConfig(STARTUP_CONFIG_NAME);
    private static final String CENTRALIZED_MANAGEMENT = "values";
    public static final String LIGHT_ENV = "light-env";

    public static final String DEFAULT_ENV = "dev";
    public static final String DEFAULT_TARGET_CONFIGS_DIRECTORY ="src/main/resources/config";

    public static final String CONFIG_SERVER_URI = "light-config-server-uri";
    public static final String CONFIG_SERVER_CONFIGS_CONTEXT_ROOT = "/config-server/configs";
    public static final String CONFIG_SERVER_CERTS_CONTEXT_ROOT = "/config-server/certs";
    public static final String CONFIG_SERVER_FILES_CONTEXT_ROOT = "/config-server/files";
    public static final String AUTHORIZATION = "config_server_authorization";
    public static final String CLIENT_TRUSTSTORE_PASS = "config_server_client_truststore_password";
    public static final String CLIENT_TRUSTSTORE_LOC = "config_server_client_truststore_location";
    public static final String VERIFY_HOST_NAME = "config_server_client_verify_host_name";

    public static final String PROJECT_NAME = "projectName";
    public static final String PROJECT_VERSION = "projectVersion";
    public static final String SERVICE_NAME = "serviceName";
    public static final String SERVICE_VERSION = "serviceVersion";

    public static String lightEnv = System.getProperty(LIGHT_ENV);
    public static String configServerUri = System.getProperty(CONFIG_SERVER_URI);
    public static String targetConfigsDirectory = System.getProperty(Config.LIGHT_4J_CONFIG_DIR);

    // An instance of Jackson ObjectMapper that can be used anywhere else for Json.
    final static ObjectMapper mapper = new ObjectMapper();
    // The instance of Http2Client that is used to connect to the light-config-server with bootstrap.truststore
    static Http2Client client = Http2Client.getInstance();
    ClientConnection connection = null;

    @Override
    public void init() {
        if (lightEnv == null) {
            logger.warn("Warning! {} is not provided; defaulting to {}", LIGHT_ENV, DEFAULT_ENV);
            lightEnv = DEFAULT_ENV;
        }
        if (targetConfigsDirectory == null) {
            logger.warn("Warning! {} is not provided; defaulting to {}", Config.LIGHT_4J_CONFIG_DIR, DEFAULT_TARGET_CONFIGS_DIRECTORY);
            targetConfigsDirectory = DEFAULT_TARGET_CONFIGS_DIRECTORY;
        }

        if (configServerUri != null) {
            logger.info("Loading configs from config server");

            try {
                connection = client.connect(new URI(configServerUri), Http2Client.WORKER, client.createXnioSsl(createBootstrapContext()), Http2Client.BUFFER_POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get();
                String configPath = getConfigServerPath();

                loadConfigs(configPath);

                loadFiles(configPath, CONFIG_SERVER_CERTS_CONTEXT_ROOT);

                loadFiles(configPath, CONFIG_SERVER_FILES_CONTEXT_ROOT);
            } catch (Exception e) {
                logger.error("Failed to connect to config server", e);
            }finally {
                // here the connection is closed after one request. It should be used for in frequent
                // request as creating a new connection is costly with TLS handshake and ALPN.
                IoUtils.safeClose(connection);
            }



            try {
                String filename = System.getProperty("logback.configurationFile");
                if (filename != null && Files.exists(Paths.get(filename))) {
                    // reset the default context (which may already have been initialized)
                    // since we want to reconfigure it
                    logger.info("Resetting logback configuration from {}", filename);
                    LoggerContext lc = (LoggerContext)LoggerFactory.getILoggerFactory();
                    lc.reset();
                    JoranConfigurator config = new JoranConfigurator();
                    config.setContext(lc);
                    config.doConfigure(filename);
                }
            } catch (JoranException e) {
                logger.error("Logback configuration failed", e);
            }
        } else {
            logger.warn("Warning! {} is not provided; using local configs", CONFIG_SERVER_URI);
        }
    }

    /**
     * load config properties from light config server
     * @param configPath
     */
    private void loadConfigs(String configPath) {
        //config Server Configs Path
        String configServerConfigsPath = CONFIG_SERVER_CONFIGS_CONTEXT_ROOT + configPath;
        //get service configs and put them in config cache
        Map<String, Object> serviceConfigs = getServiceConfigs(configServerConfigsPath);

        //set the environment value (the one used to fetch configs) in the serviceConfigs going into configCache
        serviceConfigs.put(ENV_PROPERTY_KEY, lightEnv);
        logger.debug("serviceConfigs received from Config Server: {}", serviceConfigs);

        // pass serviceConfigs through Config.yaml's load method so that it can decrypt any encrypted values
        DumperOptions options = new DumperOptions();
        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);//to get yaml string without curly brackets and commas
        serviceConfigs = Config.getInstance().getYaml().load(new Yaml(options).dump(serviceConfigs));

        //clear config cache: this is required just in case other classes have already loaded something in cache
        Config.getInstance().clear();
        Config.getInstance().putInConfigCache(CENTRALIZED_MANAGEMENT, serviceConfigs);
        // Reset global pointer to the server.yml object
        Server.config = Server.getServerConfig();
    }

    /**
     * load config/cert files from light config server
     * @param configPath
     * @param contextRoot
     */
    private void loadFiles(String configPath, String contextRoot) {
        //config Server Files Path
        String configServerFilesPath = contextRoot + configPath;
        //get service files and put them in config dir
        Map<String, Object> serviceFiles = getServiceConfigs(configServerFilesPath);
        logger.debug("{} files loaded from config sever.", serviceFiles.size());
        logger.debug("loadFiles: {}", serviceFiles);
        try {
            Path filePath = Paths.get(targetConfigsDirectory);
            if (!Files.exists(filePath)) {
                Files.createDirectories(filePath);
                logger.info("target configs directory created :", targetConfigsDirectory);
            }
            Base64.Decoder decoder = Base64.getMimeDecoder();
            for (String fileName : serviceFiles.keySet()) {
                filePath=Paths.get(targetConfigsDirectory+"/"+fileName);
                Files.write(filePath, decoder.decode(serviceFiles.get(fileName).toString().getBytes()));
            }
        }  catch (IOException e) {
            logger.error("Exception while creating {} dir or creating files there:{}",targetConfigsDirectory, e);
        }
    }

    /**
     * This is a public method that is used to test the connectivity in the integration test to ensure that the
     * light-config-server can be connected with the default bootstrap.truststore. There is no real value for
     * this method other than that.
     *
     * @return String of OK
     */
    public static String getConfigServerHealth(String host, String path) {
        String result = null;
        try {
            final CountDownLatch latch = new CountDownLatch(1);
            ClientConnection connection = client.connect(new URI(host), Http2Client.WORKER, client.createXnioSsl(createBootstrapContext()), Http2Client.BUFFER_POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get();
            final AtomicReference<ClientResponse> reference = new AtomicReference<>();
            try {
                final ClientRequest request = new ClientRequest().setMethod(Methods.GET).setPath(path);
                request.getRequestHeaders().put(Headers.HOST, host);
                connection.sendRequest(request, client.createClientCallback(reference, latch));
                latch.await(1000, TimeUnit.MILLISECONDS);
            } finally {
                // here the connection is closed after one request. It should be used for in frequent
                // request as creating a new connection is costly with TLS handshake and ALPN.
                IoUtils.safeClose(connection);
            }
            int statusCode = reference.get().getResponseCode();
            String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY);
            if (statusCode >= 300) {
                logger.error("Failed to load configs from config server" + statusCode + ":" + body);
                throw new Exception("Failed to load configs from config server: " + statusCode);
            } else {
                result = body;
            }
        } catch (Exception e) {
            logger.error("Exception while calling config server:", e);
        }
        return result;
    }

    private Map<String, Object> getServiceConfigs(String configServerPath) {
        String authorization = System.getenv(AUTHORIZATION);
        String verifyHostname = System.getenv(VERIFY_HOST_NAME);

        Map<String, Object> configs = new HashMap<>();

        logger.debug("Calling Config Server endpoint:{}{}", configServerUri, configServerPath);

        try {
            final CountDownLatch latch = new CountDownLatch(1);
            final AtomicReference<ClientResponse> reference = new AtomicReference<>();

            final ClientRequest request = new ClientRequest().setMethod(Methods.GET).setPath(configServerPath);
            request.getRequestHeaders().put(Headers.AUTHORIZATION, authorization);
            request.getRequestHeaders().put(Headers.HOST, configServerUri);
            connection.sendRequest(request, client.createClientCallback(reference, latch));
            latch.await(10000, TimeUnit.MILLISECONDS);

            int statusCode = reference.get().getResponseCode();
            String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY);
            if (statusCode >= 300) {
                logger.error("Failed to load configs from config server" + statusCode + ":" + body);
                throw new Exception("Failed to load configs from config server: " + statusCode);
            } else {
                // Get the response
                Map<String, Object> responseMap = (Map<String, Object>) mapper.readValue(body, new TypeReference<Map<String, Object>>() {});
                configs = (Map<String, Object>) responseMap.get("configProperties");
            }
        } catch (Exception e) {
            logger.error("Exception while calling config server:", e);
        }
        return configs;
    }

    private static String getConfigServerPath() {
        StringBuilder configPath = new StringBuilder();
        configPath.append("/").append(startupConfig.get(PROJECT_NAME));
        configPath.append("/").append(startupConfig.get(PROJECT_VERSION));
        configPath.append("/").append(startupConfig.get(SERVICE_NAME));
        configPath.append("/").append(startupConfig.get(SERVICE_VERSION));
        configPath.append("/").append(lightEnv);
        logger.debug("configPath: {}", configPath);
        return configPath.toString();
    }

    private static KeyStore loadBootstrapTrustStore(){
        String truststorePassword = System.getenv(CLIENT_TRUSTSTORE_PASS);
        String truststoreLocation = System.getenv(CLIENT_TRUSTSTORE_LOC);
        if(truststoreLocation == null) truststoreLocation = Server.getServerConfig().getBootstrapStoreName();
        if(truststorePassword == null) truststorePassword = Server.getServerConfig().getBootstrapStorePass();

        try (InputStream stream = new FileInputStream(truststoreLocation)) {
            if (stream == null) {
                String message = "Unable to load truststore '" + truststoreLocation + "', please provide the correct truststore to enable TLS connection.";
                if (logger.isErrorEnabled()) {
                    logger.error(message);
                }
                throw new RuntimeException(message);
            }
            KeyStore loadedKeystore = KeyStore.getInstance("JKS");
            loadedKeystore.load(stream, truststorePassword != null ? truststorePassword.toCharArray() : null);
            return loadedKeystore;
        } catch (Exception e) {
            logger.error("Unable to load truststore: " + truststoreLocation, e);
            throw new RuntimeException("Unable to load truststore: " + truststoreLocation, e);
        }
    }

    private static TrustManager[] buildTrustManagers(final KeyStore trustStore) {
        TrustManager[] trustManagers = null;
        try {
            TrustManagerFactory trustManagerFactory = TrustManagerFactory
                    .getInstance(KeyManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init(trustStore);
            trustManagers = trustManagerFactory.getTrustManagers();
        } catch (NoSuchAlgorithmException | KeyStoreException e) {
            logger.error("Unable to initialise TrustManager[]", e);
            throw new RuntimeException("Unable to initialise TrustManager[]", e);
        }
        return trustManagers;
    }


    private static SSLContext createBootstrapContext() throws RuntimeException {
        SSLContext sslContext = null;
        try {
            TrustManager[] trustManagers = buildTrustManagers(loadBootstrapTrustStore());
            sslContext = SSLContext.getInstance("TLSv1.2");
            sslContext.init(null, trustManagers, null);
        } catch (Exception e) {
            logger.error("Unable to create SSLContext", e);
            throw new RuntimeException("Unable to create SSLContext", e);
        }
        return sslContext;
    }
}