package be.iminds.ilabt.jfed.lowlevel.connection;

import be.iminds.ilabt.jfed.lowlevel.JFedException;
import be.iminds.ilabt.jfed.util.*;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.DnsResolver;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustStrategy;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.conn.SystemDefaultDnsResolver;
import org.apache.http.protocol.HttpContext;
import org.apache.http.ssl.SSLContexts;
import org.apache.xmlrpc.client.XmlRpcClient;
import org.apache.xmlrpc.client.XmlRpcClientConfigImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;

/**
 * ConnectionFactory
 */
public class BasicConnectionBuilder implements ConnectionBuilder {
    private static final Logger LOG = LoggerFactory.getLogger(BasicConnectionBuilder.class);
    private static final SSLConnectionSocketFactory unsafeSslSocketFactory = createUnsafeSslConnectionSocketFactory();
    //password for internal use. We don't really need one as we'll never save this keystore or share it with other objects
    private static final String PRIVATEKEY_STORE_PASS = "someotherpass";
    //password for internal use. We don;t really need one as we'll never save this keystore.
    private static final String KEY_STORE_PASS = "somepass";

    private static SSLConnectionSocketFactory createUnsafeSslConnectionSocketFactory() {
        try {
            SSLContext sslcontext = SSLContext.getInstance("TLS");
            sslcontext.init(null, null, null);
            return new SSLConnectionSocketFactory(sslcontext, NoopHostnameVerifier.INSTANCE);

        } catch (NoSuchAlgorithmException | KeyManagementException e) {
            throw new RuntimeException("Could not create an unsafe connection manager", e);
        }

    }

//    private HttpClientConnectionManager createUnsafeClientConnectionManager(DnsResolver dnsResolver) {
//        return new PoolingHttpClientConnectionManager(UNSAFE_SSL_CONNECTION_SOCKET_FACTORY, null, dnsResolver);
//    }

    private URL serverUrl;
    private String serverUrlString;
    private JFedConnection.DebugInfo debugInfo;
    private JFedConnection.ProxyInfo proxyInfo;
    private ConnectionConfig.Authentication authentication;
    private JFedPasswordManager.LoginInfo login;
    private List<X509Certificate> clientCertChain;
    private PrivateKey privateKey;
    private ConnectionConfig.Protocol protocol;
    private JFedTrustStore conTrustStore;
    private HandleUntrustedCallback handleUntrustedCallback;
    private boolean forceSshProxyDns = false;

    private boolean hackAllowAllServerCertificates = false;
    private List<String> hackOverrideAllowedServerCertificateAlias = null; //add "*" to accept anything

    private final String userAgent;

    /**
     * class private. In fact, should only be called from BasicConnectionBuilderFactory
     */
    BasicConnectionBuilder() {
        this.userAgent = null;
    }

    BasicConnectionBuilder(String userAgent) {
        this.userAgent = userAgent;
    }

    @Override
    public void setUrl(String serverUrlString) throws MalformedURLException {
        this.serverUrlString = serverUrlString;
        this.serverUrl = new URL(serverUrlString);
    }

    @Override
    public void setUrl(URL serverUrl) {
        this.serverUrl = serverUrl;
        this.serverUrlString = serverUrl.toExternalForm();
    }

    @Override
    public void setHackAllowAllServerCertificates(boolean hackAllowAllServerCertificates) {
        this.hackAllowAllServerCertificates = hackAllowAllServerCertificates;
    }

    @Override
    public void setHackOverrideAllowedServerCertificateAlias(List<String> hackOverrideAllowedServerCertificateAlias) {
        this.hackOverrideAllowedServerCertificateAlias = hackOverrideAllowedServerCertificateAlias;
    }

    @Override
    public void setDebugInfo(JFedConnection.DebugInfo debugInfo) {
        this.debugInfo = debugInfo;
    }

    @Override
    public void setProxy(JFedConnection.ProxyInfo proxyInfo, boolean forceSshProxyDns) {
        this.proxyInfo = proxyInfo;
        this.forceSshProxyDns = forceSshProxyDns;
    }

    @Override
    public void useNoAuthentication() {
        this.authentication = ConnectionConfig.Authentication.NONE;
    }

    @Override
    public void useHttpBasicAuthentication(JFedPasswordManager.LoginInfo login) {
        this.authentication = ConnectionConfig.Authentication.HTTP_BASIC;
        this.login = login;
        if (login == null)
            throw new IllegalArgumentException("Illegal argument: login == null");
    }

    @Override
    public void useSslClientAuthentication(List<X509Certificate> clientCertChain, PrivateKey privateKey) {
        this.authentication = ConnectionConfig.Authentication.SSL_CLIENT_AUTH;
        this.clientCertChain = clientCertChain;
        this.privateKey = privateKey;
        if (privateKey == null)
            throw new IllegalArgumentException("Illegal argument: privateKey == null");
        if (clientCertChain == null)
            throw new IllegalArgumentException("Illegal argument: clientCertificateChain == null");
        if (clientCertChain.isEmpty())
            throw new IllegalArgumentException("Illegal argument: clientCertChain is empty");
    }

    @Override
    public void useHttp() {
        this.protocol = ConnectionConfig.Protocol.HTTP;
    }

    @Override
    public void useHttps(JFedTrustStore conTrustStore, HandleUntrustedCallback handleUntrustedCallback) {
        this.protocol = ConnectionConfig.Protocol.HTTPS;
        this.conTrustStore = conTrustStore;
        this.handleUntrustedCallback = handleUntrustedCallback;
        if (conTrustStore == null)
            throw new IllegalArgumentException("Illegal argument: conTrustStore == null");
    }

    private void check() {
        if (debugInfo == null) throw new RuntimeException("Configuration problem: must specify debugInfo");
        if (serverUrlString == null || serverUrl == null)
            throw new RuntimeException("Configuration problem: must specify URL");
        if (authentication == null) throw new RuntimeException("Configuration problem: must specify authentication");
        if (protocol == null) throw new RuntimeException("Configuration problem: must specify HTTP or HTTPS");
        if (protocol != ConnectionConfig.Protocol.HTTPS && authentication == ConnectionConfig.Authentication.SSL_CLIENT_AUTH)
            throw new RuntimeException("Configuration problem: incompatible options: cannot do SSL client authentication without HTTPS.");
    }


    private static class HttpConParts {
        private final HttpClientBuilder clientBuilder;
        private final HttpContext localContext;

        private HttpConParts(HttpClientBuilder clientBuilder, HttpContext localContext) {
            this.clientBuilder = clientBuilder;
            this.localContext = localContext;
        }

        public HttpClientBuilder getClientBuilder() {
            return clientBuilder;
        }

        public HttpContext getLocalContext() {
            return localContext;
        }
    }

    private static class SpyingTrustStrategy implements TrustStrategy {
        private final TrustStrategy orig;
        public X509Certificate[] chain;
        private JFedConnection connection;

        public SpyingTrustStrategy(TrustStrategy orig) {
            this.orig = orig;
        }

        @Override
        public boolean isTrusted(X509Certificate[] certificate, String authType) throws CertificateException {
            if (certificate != null && certificate.length > 0) {
                chain = certificate;
                if (connection != null)
                    connection.registerServerCertficates(chain);
            }
            return orig.isTrusted(certificate, authType);
        }

        public void setConnection(JFedConnection connection) {
            LOG.debug("registering connection to SpyingTrustStrategy");
            this.connection = connection;
            if (chain != null)
                connection.registerServerCertficates(chain);
        }
    }

    private InetAddress[] dummyDnsResolver(String host) throws UnknownHostException {
        //do not resolve if going over SSH proxy, let the proxy do that
        if (!Objects.equals(host, serverUrl.getHost())) {
            LOG.error("This hack expected resolve of \"" + serverUrl.getHost() + "\" but was requested to resolve \"" + host + "\". Reverting to normal resolve.");
            return SystemDefaultDnsResolver.INSTANCE.resolve(host);
        }
        //return fake resolve
        return new InetAddress[]{InetAddress.getByAddress(host, new byte[]{127, 0, 0, 1})};
    }

    private HttpConParts getConnectionHttpClientAndContext() throws JFedException {
        final HttpClientBuilder clientBuilder = HttpClients.custom();
        final HttpClientContext localContext = HttpClientContext.create();

        clientBuilder.setUserAgent(userAgent);

        clientBuilder.setDefaultRequestConfig(RequestConfig.custom()
                .setConnectTimeout(10_000) //connection timeout in ms (default: 0 = infinite)
                .setSocketTimeout(120_000) //read timeout in ms (default: 0 = infinite)   (long timeout, as some calls take time to finish)
                .build());


        boolean useForceSshProxyDns = proxyInfo instanceof JFedConnection.SshProxyInfo && this.forceSshProxyDns;

        //DNS resolver needs to be stopped if this request is sent over SSH proxy.
        //because the SSH proxy will do the resolve.
        final DnsResolver dnsResolver =
                !useForceSshProxyDns ? SystemDefaultDnsResolver.INSTANCE : this::dummyDnsResolver;

        final RegistryBuilder<ConnectionSocketFactory> socketFactoryRegistryBuilder = RegistryBuilder.create();

        if (protocol == ConnectionConfig.Protocol.HTTPS) {
            try {
                SSLContext sslContext;
                HostnameVerifier hostnameVerifier;

                if (hackAllowAllServerCertificates) {
                    sslContext = createUnsafeSslContext();
                    hostnameVerifier = NoopHostnameVerifier.INSTANCE;
                } else {
                    sslContext = createSslContext();
                    hostnameVerifier = createHostnameVerifier();
                }

                SSLConnectionSocketFactory sslSocketFactory;
                if (proxyInfo != null && proxyInfo instanceof JFedConnection.SshProxyInfo) {
                    JFedConnection.SshProxyInfo sshProxyInfo = (JFedConnection.SshProxyInfo) proxyInfo;
                    LOG.debug("Using SSH Proxy for connection: " + sshProxyInfo.getHostname());

                    if (useForceSshProxyDns) {
                        int serverPort = serverUrl.getPort() <= 0 ? serverUrl.getDefaultPort() : serverUrl.getPort();

                        sslSocketFactory = new SshServerProxyHelper.SslOverSshProxySocketFactory(
                                sslContext, hostnameVerifier, sshProxyInfo,
                                serverUrl.getHost(), serverPort);
                    } else {
                        sslSocketFactory = new SshServerProxyHelper.SslOverSshProxySocketFactory(
                                sslContext, hostnameVerifier, sshProxyInfo);
                    }

                } else {
                    sslSocketFactory = new SSLConnectionSocketFactory(sslContext, hostnameVerifier);
                }

                socketFactoryRegistryBuilder.register("https", sslSocketFactory);

            } catch (Exception e) {
                throw new JFedException("Error creating SSL connection to " + serverUrlString, e);
            }
        }

        if (protocol == ConnectionConfig.Protocol.HTTP) {
            ConnectionSocketFactory socketFactory;

            if (proxyInfo != null && proxyInfo instanceof JFedConnection.SshProxyInfo) {
                JFedConnection.SshProxyInfo sshProxyInfo = (JFedConnection.SshProxyInfo) proxyInfo;

                LOG.debug("Using SSH Proxy HTTP connection: " + sshProxyInfo.getHostname());

                int serverPort = serverUrl.getPort() <= 0 ? serverUrl.getDefaultPort() : serverUrl.getPort();
                socketFactory =
                        new SshServerProxyHelper.PlainHttpOverSshProxySocketFactory(sshProxyInfo,
                                useForceSshProxyDns ? serverUrl.getHost() : null,
                                useForceSshProxyDns ? serverPort : null);
            } else if (isSystemSocksProxyEnabled()) {
                socketFactory = new SocksProxyHelper.SOCKSConnectionSocketFactory();
            } else {
                socketFactory = PlainConnectionSocketFactory.INSTANCE;
            }

            socketFactoryRegistryBuilder.register("http", socketFactory);
        }

        clientBuilder.setConnectionManager(
                new PoolingHttpClientConnectionManager(socketFactoryRegistryBuilder.build(), dnsResolver));


        if (authentication == ConnectionConfig.Authentication.HTTP_BASIC) {

            int serverPort = serverUrl.getPort() <= 0 ? serverUrl.getDefaultPort() : serverUrl.getPort();
            final BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
            credentialsProvider.setCredentials(
                    new AuthScope(serverUrl.getHost(), serverPort),
                    new UsernamePasswordCredentials(login.getUsername(), login.getPassword()));

            clientBuilder.setDefaultCredentialsProvider(credentialsProvider);
            //credentials are now ready, and will be provided when server ask for it.
            //below we will construct a HttpContext that can be used to enable preemptive authentication for specific calls
            //    (= send the authentication before the server asks for it)

            //TODO: Currently, onlt HTTP / REST conenctions support this, SFA does not.

            AuthCache authCache = new BasicAuthCache();
            final HttpHost targetHost = new HttpHost(serverUrl.getHost(), serverPort,
                    protocol == ConnectionConfig.Protocol.HTTP ? "http" : "https");
            BasicScheme basicAuth = new BasicScheme();
            authCache.put(targetHost, basicAuth);

            // Add AuthCache to the execution context
            localContext.setAuthCache(authCache);
        }

        return new HttpConParts(clientBuilder, localContext);
    }

    @Nonnull
    private HostnameVerifier createHostnameVerifier() {
        final List<String> allowedCertificateHostnameAliases =
                hackOverrideAllowedServerCertificateAlias != null ?
                        new ArrayList<>(hackOverrideAllowedServerCertificateAlias) :
                        new ArrayList<>(conTrustStore.getAllowedServerCertificateHostnameAliases());
        LOG.debug("Creating HostnameVerifier with" +
                " allowedCertificateHostnameAliases=" + allowedCertificateHostnameAliases
                + " for serverUrl=" + serverUrlString);
        return new AliasAllowingHostnameVerifier(allowedCertificateHostnameAliases);
    }

    private SSLContext createUnsafeSslContext() throws KeyManagementException, NoSuchAlgorithmException {
        return SSLContexts.custom().useProtocol(SSLConnectionSocketFactory.TLS).build();
    }

    private SSLContext createSslContext() throws KeyStoreException, NoSuchProviderException, IOException, NoSuchAlgorithmException, CertificateException, JFedException, KeyManagementException, UnrecoverableKeyException {
        final KeyStore trustStore = conTrustStore.getTrustStore();
        KeyStore keyStore;

        if (authentication == ConnectionConfig.Authentication.SSL_CLIENT_AUTH) {
            keyStore = createKeyStore(trustStore);
        } else {
            keyStore = null;
        }


        TrustStrategy trustStrategy;
        if (handleUntrustedCallback != null) {
            /* This is probably not a good implementation of the default TrustStrategy! Too bad the real default is nowhere to be found. */
            trustStrategy = (certificates, authType) -> {
                if (certificates == null || certificates.length == 0)
                    return false;
                try {
                    for (X509Certificate cert : certificates) {
                        String alias = trustStore.getCertificateAlias(cert);
                        if (alias != null)
                            return true;
                    }
                } catch (KeyStoreException e) {
                    throw new CertificateException("problem with keystore while checking certificate", e);
                }
                SSLCertificateDownloader.SSLCertificateJFedInfo certInfo =
                        new SSLCertificateDownloader.SSLCertificateJFedInfo(certificates[0], serverUrl, serverUrl.getHost());
                return handleUntrustedCallback.trust(certInfo);
            };

            LOG.debug("using home-made trustStrategy, this is far from perfect! Might be insecure!");

            LOG.debug("adding SpyingTrustStrategy");
            trustStrategy = new SpyingTrustStrategy(trustStrategy);
        } else {
            trustStrategy = null;
        }

        return SSLContexts.custom()
                .useProtocol(SSLConnectionSocketFactory.TLS)
                .setSecureRandom(new SecureRandom())
                .loadKeyMaterial(keyStore, PRIVATEKEY_STORE_PASS.toCharArray())
                .loadTrustMaterial(trustStore, trustStrategy)
                .build();
    }

    private static boolean isSystemSocksProxyEnabled() {
        return Objects.equals(System.getProperty("proxySet"), "true") &&
                System.getProperty("socksProxyHost") != null;
    }

    @Nullable
    private KeyStore createKeyStore(KeyStore trustStore) throws KeyStoreException, NoSuchProviderException, IOException, NoSuchAlgorithmException, CertificateException, JFedException {
        KeyStore keyStore = KeyStore.getInstance("JKS", "SUN");
        keyStore.load(null, KEY_STORE_PASS.toCharArray());

        // Create CertificateChain
        Certificate[] certs = new Certificate[clientCertChain.size()];
        for (int i = 0; i < clientCertChain.size(); i++) {
            X509Certificate c = clientCertChain.get(i);
            certs[i] = c;
            try {
                //FEDIBBTDEV-878: check certificate validity
                c.checkValidity(new Date());
            } catch (CertificateExpiredException e) {
                throw new JFedException("Certificate " + i + " (of " + clientCertChain.size() + ") in the user " +
                        "certificate chain has expired. NotAfter=" + c.getNotAfter() + " now=" + new Date());
            } catch (CertificateNotYetValidException e) {
                throw new JFedException("Certificate " + i + " (of " + clientCertChain.size() + ") in the user " +
                        "certificate chain is not yet valid. NotBefore=" + c.getNotBefore() + " now=" + new Date());
            }
        }

        // storing private key and certificate to keystore
        keyStore.setKeyEntry("authority", privateKey, PRIVATEKEY_STORE_PASS.toCharArray()/*password is used to encrypt the key inside the keystore*/, certs);

        //add all client certificates to trust store as well as keystore!
        for (int i = 0; i < clientCertChain.size(); i++)
            trustStore.setCertificateEntry("clientCertificate" + i, clientCertChain.get(i));

        return keyStore;
    }


    @Override
    public SfaConnection buildSfaConnection() throws JFedException {
        check();

        HttpConParts httpConParts = getConnectionHttpClientAndContext();

        try {
            final XmlRpcClient xmlRpcClient = new XmlRpcClient();

            //support <nil/>
            xmlRpcClient.setTypeFactory(new XmlRpcTypeNil(xmlRpcClient));

            XmlRpcClientConfigImpl config = new XmlRpcClientConfigImpl();
            config.setServerURL(serverUrl);
            xmlRpcClient.setConfig(config);

            CommonsHttpClientXmlRpcTransportFactory xmlRpcTransportFactory =
                    new CommonsHttpClientXmlRpcTransportFactory(xmlRpcClient, httpConParts.getClientBuilder(), httpConParts.getLocalContext());
            xmlRpcClient.setTransportFactory(xmlRpcTransportFactory);


            ConnectionConfig connectionConfig = getCurrentConnectionConfig(ConnectionConfig.Type.SFA);


            BasicSfaConnection res = new BasicSfaConnection(connectionConfig, xmlRpcTransportFactory, xmlRpcClient);

//FIXME what to do with SypingTrustStrategy??
//            if (httpConParts.trustStrategy != null && httpConParts.trustStrategy instanceof SpyingTrustStrategy) {
//                ((SpyingTrustStrategy) httpConParts.trustStrategy).setConnection(res);
//            }
            return res;
        } catch (Exception e) {
            throw new JFedException("Error creating XmlRpcClient: " + e.getMessage(), e);
        }
    }

    @Override
    public HttpConnection buildHttpConnection() throws JFedException {
        check();

        HttpConParts httpConParts = getConnectionHttpClientAndContext();

        ConnectionConfig connectionConfig = getCurrentConnectionConfig(ConnectionConfig.Type.HTTP);
        BasicHttpConnection res =
                new BasicHttpConnection(connectionConfig, httpConParts.getClientBuilder(), new HttpClientContext(), httpConParts.getLocalContext());

//FIXME what to do with SypingTrustStrategy??
//            if (httpConParts.trustStrategy != null && httpConParts.trustStrategy instanceof SpyingTrustStrategy) {
//                ((SpyingTrustStrategy) httpConParts.trustStrategy).setConnection(res);
//
        return res;
    }

    @Override
    public ConnectionConfig getCurrentConnectionConfig(ConnectionConfig.Type type) {
        return new ConnectionConfig(serverUrl, debugInfo, proxyInfo, authentication, login, clientCertChain, privateKey, protocol, conTrustStore, handleUntrustedCallback, type);
    }

    @Override
    public void set(ConnectionConfig config) {
        this.serverUrlString = config.getServerUrlString();
        this.debugInfo = config.getDebugInfo();
        this.proxyInfo = config.getProxyInfo();
        this.authentication = config.getAuthentication();
        this.login = config.getLogin();
        this.clientCertChain = config.getClientCertChain();
        this.privateKey = config.getPrivateKey();
        this.protocol = config.getProtocol();
        this.conTrustStore = config.getConTrustStore();
        this.handleUntrustedCallback = config.getHandleUntrustedCallback();
    }


    public static class INSECURE_TRUSTALL_HandleUntrustedCallback implements HandleUntrustedCallback {
        public INSECURE_TRUSTALL_HandleUntrustedCallback() {
            LOG.warn("SECURITY WARNING: constructing INSECURE_TRUSTALL_HandleUntrustedCallback");
        }

        @Override
        public boolean trust(SSLCertificateDownloader.SSLCertificateJFedInfo sSLCertificateJFedInfo) {
            return true;
        }
    }


}
