package be.iminds.ilabt.jfed.preferences;

import be.iminds.ilabt.jfed.fedmon.webapi.service.json.Proxy;
import be.iminds.ilabt.jfed.fedmon.webapi.service.json.Server;
import be.iminds.ilabt.jfed.lowlevel.GeniUser;
import be.iminds.ilabt.jfed.lowlevel.GeniUserProvider;
import be.iminds.ilabt.jfed.lowlevel.connection.JFedConnection;
import be.iminds.ilabt.jfed.lowlevel.connection.SfaConnectionPool;
import be.iminds.ilabt.jfed.lowlevel.ssh_key_info.SshKeyInfo;
import be.iminds.ilabt.jfed.lowlevel.ssh_key_info.SshKeyInfoFactory;
import be.iminds.ilabt.jfed.lowlevel.stitching.VlanRange;
import javafx.concurrent.Task;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

/**
 * ProxyPreferencesManager
 * <p/>
 * This class sets the preferences from PreferencesUtil, combined with data about failed connections
 * to the ProxySettings in an SfaConnectionPool
 */
@Singleton
public class ProxyPreferencesManager {
    public static final String ALWAYS_USE_PROXY = "ALWAYS";
    public static final String NEVER_USE_PROXY = "NEVER";
    public static final String AUTO_USE_PROXY = "AUTO";
    private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(ProxyPreferencesManager.class);
    private static final int MAX_PORTS_TESTED = 10;
    private static final int CONNECT_TIMEOUT = 5 * 1000; //5 seconds
    private final GeniUserProvider geniUserProvider;
    private final SfaConnectionPool connectionPool;
    private final JFedCorePreferences jFedPreferences;
    private final List<ProxyPort> reachableProxies = new ArrayList<>();
    private final List<ProxyPort> unreachableProxies = new ArrayList<>();
    private boolean needProxyForJFed;
    //    private boolean prevNeedProxyForSsh = false;
    private boolean needProxyForSsh;
    private GeniUser prevUser;
    private JFedConnection.ProxyInfo prevProxy;
    private boolean prevNeedProxyForJFed = false;

    @Inject
    public ProxyPreferencesManager(GeniUserProvider geniUserProvider, SfaConnectionPool connectionPool, JFedCorePreferences jFedPreferences) {
        this.geniUserProvider = geniUserProvider;
        this.connectionPool = connectionPool;
        this.jFedPreferences = jFedPreferences;

        updateProxySettings();
    }

    public void reportUnreachableProxy(Proxy proxyInfo, int port) {
        if (proxyInfo != null)
            unreachableProxies.add(new ProxyPort(proxyInfo, port));
    }

    public void reportReachableProxy(Proxy proxyInfo, int port) {
        reachableProxies.add(new ProxyPort(proxyInfo, port));
    }

    public void resetProxyReachabilityCache() {
        reachableProxies.clear();
        unreachableProxies.clear();
    }

    public JFedConnection.SshProxyInfo chooseUserProxy(List<Proxy> userAuthProxyInfo, GeniUser user, SshKeyInfo userSshKeyInfo) {
        if (reachableProxies.isEmpty() && unreachableProxies.isEmpty()) {
            //if no info available, automatically test
            waitForTryProxyTask(user);
        }

        final String username = user.getUserUrn().getEncodedResourceName();

        if (!reachableProxies.isEmpty()) {
            ProxyPort pp = reachableProxies.get(0);
            assert pp != null;
            LOG.debug("chooseUserProxy return reachableProxies 0 of " + reachableProxies.size() + ": " + pp + "  -> reachableProxies=" + reachableProxies + " unreachableProxies=" + unreachableProxies);
            return new JFedConnection.SshProxyInfo(
                    pp.getHostname(), pp.getPort(),
                    username /*user's username is always the username used at the authority proxy*/,
                    userSshKeyInfo /* authority proxy always uses user login file keys */,
                    pp.getProxyInfo().getHostKey(),
                    true);
        }

        for (Proxy authProxyInfo : userAuthProxyInfo) {
            assert Objects.equals(authProxyInfo.getType(), "jFed SSH Proxy");

            VlanRange portRange = new VlanRange(authProxyInfo.getPortRange());
            LOG.debug("chooseUserProxy is looking at proxy with ports=" + portRange + " while unreachableProxies=" + unreachableProxies);
            ProxyPort chosenPort = null;
            for (Integer port : portRange) {
                ProxyPort curPort = new ProxyPort(authProxyInfo, port);
                if (!unreachableProxies.contains(curPort)) {
                    chosenPort = curPort;
                    break;
                }
            }

            if (chosenPort == null) {
                LOG.debug("Proxy at " + authProxyInfo.getHostname() + " has no reachable ports, skipping.");
                continue;
            } else
                LOG.debug("chooseUserProxy search resulted in port=" + chosenPort.getPort());

            return new JFedConnection.SshProxyInfo(
                    authProxyInfo.getHostname(), chosenPort.getPort(),
                    username /*user's username is always the username used at the authority proxy*/,
                    userSshKeyInfo /* authority proxy always uses user login file keys */,
                    authProxyInfo.getHostKey(),
                    true);
        }
        return null;
    }

    public boolean isAnyProxyReachable() {
        return !reachableProxies.isEmpty();
    }

    public List<ProxyPort> getReachableProxies() {
        return Collections.unmodifiableList(reachableProxies);
    }

    public void setNeedProxyForJFed(boolean needProxyForJFed) {
        this.needProxyForJFed = needProxyForJFed;
        updateProxySettings();
    }

    public void setNeedProxyForSsh(boolean needProxyForSsh) {
        this.needProxyForSsh = needProxyForSsh;
        updateProxySettings();
    }

    public boolean isProxyEnabledForJfed() {
        String proxyUsage = jFedPreferences.getString(CorePreferenceKey.PREF_SSHPROXY_USE_FOR_JFED);
        if (proxyUsage.equalsIgnoreCase(NEVER_USE_PROXY))
            return false;
        if (proxyUsage.equalsIgnoreCase(AUTO_USE_PROXY))
            return needProxyForJFed;
        if (proxyUsage.equalsIgnoreCase(ALWAYS_USE_PROXY))
            return true;
        throw new RuntimeException("illegal value: proxyUsage=" + proxyUsage + "");
    }

    public boolean isProxyEnabledForSsh() {
        String proxyUsage = jFedPreferences.getString(CorePreferenceKey.PREF_SSHPROXY_USE_FOR_SSH);
        if (proxyUsage.equalsIgnoreCase(NEVER_USE_PROXY))
            return false;
        if (proxyUsage.equalsIgnoreCase(AUTO_USE_PROXY))
            return needProxyForJFed;
        if (proxyUsage.equalsIgnoreCase(ALWAYS_USE_PROXY))
            return true;
        throw new RuntimeException("illegal value: proxyUsage=" + proxyUsage + "");
    }

    public boolean isDnsOverProxyRequiredForJFed() {
        Boolean useDnsOverSsh =
                jFedPreferences.getBoolean(CorePreferenceKey.PREF_SSHPROXY_DNS_OVER_PROXY_JFED, false/*fallback*/);
        return useDnsOverSsh;
    }

    public boolean isDnsOverProxyRequiredForSsh() {
        Boolean useDnsOverSsh =
                jFedPreferences.getBoolean(CorePreferenceKey.PREF_SSHPROXY_DNS_OVER_PROXY_SSH, false/*fallback*/);
        return useDnsOverSsh;
    }

    @Nullable
    public JFedConnection.SshProxyInfo getSshProxySettings(List<Proxy> userAuthProxyInfo, GeniUser user) {
        return getProxySettings(CorePreferenceKey.PREF_SSHPROXY_USE_FOR_SSH, userAuthProxyInfo, user);
    }

    @Nullable
    public JFedConnection.SshProxyInfo getProxySettings(JFedPreferences.PreferenceKey preferencesKey, @Nullable List<Proxy> userAuthProxyInfo, GeniUser user) {
        String proxyUsage = jFedPreferences.getString(preferencesKey);
        if (proxyUsage.equalsIgnoreCase(NEVER_USE_PROXY)) {
            return null;
        }
        if (proxyUsage.equalsIgnoreCase(AUTO_USE_PROXY) && preferencesKey == CorePreferenceKey.PREF_SSHPROXY_USE_FOR_SSH && !needProxyForSsh) {
            return null;
        }
        if (proxyUsage.equalsIgnoreCase(AUTO_USE_PROXY) && preferencesKey == CorePreferenceKey.PREF_SSHPROXY_USE_FOR_JFED && !needProxyForJFed) {
            return null;
        }
        if (proxyUsage.equalsIgnoreCase(ALWAYS_USE_PROXY) || (proxyUsage.equalsIgnoreCase(AUTO_USE_PROXY) && needProxyForSsh)) {

            //find user proxy info (always use user login PEM keys, even if user has specified custom key)
            return chooseUserProxy(userAuthProxyInfo != null ? userAuthProxyInfo
                    : Collections.emptyList(), user, SshKeyInfoFactory.createGeniUserSshKeyInfo(user));

        }
        throw new RuntimeException("Unknown proxy setting for PREF_SSHPROXY_USE_FOR_SSH: \"" + proxyUsage + "\"");
    }

    /**
     * If needed, changes the settings of the connectionPool to reflect the new proxy settings.
     * This means this updates the proxy for new jFed's connections.
     * It does not change SSH settings. SSH proxy settings are requested directly by the ExternalSshTerminal object each time a terminal is started using getSshProxySettings().
     */
    public void updateProxySettings() {
        LOG.debug("ProxyPreferencesManager.updateProxySettings()");

        if (!geniUserProvider.isUserLoggedIn()) return;
        GeniUser currentUser = geniUserProvider.getLoggedInGeniUser();

        Server userAuth = currentUser.getUserAuthorityServer();
        assert userAuth != null;
        LOG.debug("userAuth= {}", userAuth.getId());
        LOG.debug("userAuth.getTestbed().getProxies()={}", userAuth.getTestbed().getProxies());

        JFedConnection.ProxyInfo usedProxy = getProxySettings(CorePreferenceKey.PREF_SSHPROXY_USE_FOR_JFED,
                userAuth.getTestbed().getProxies(), currentUser);

        if (prevUser == currentUser &&
                usedProxy != null && prevProxy != null && Objects.equals(prevProxy, usedProxy)
                && needProxyForJFed == prevNeedProxyForJFed)
            return; //no need to update, everything is the same

        LOG.debug("ProxyPreferencesManager.updateProxySettings() -> settings changed, will update.");

        prevUser = currentUser;
        prevNeedProxyForJFed = needProxyForJFed;
        prevProxy = usedProxy;

        //Activate proxy if present
        if (usedProxy != null) {
            LOG.info("SSH Proxy is activated for jFed calls, and there is a reachable SSH Proxy: " + usedProxy.getHostname() + ":" + usedProxy.getPort());
            connectionPool.setDefaultProxy(usedProxy);
            return;
        }

        LOG.info("No SSH Proxy for jFed calls");
        connectionPool.setDefaultProxy(null);
    }

    /**
     * check user authority proxy connectivity.
     */
    private void waitForTask(Task task) {
        Thread taskThread = new Thread(task);
        taskThread.setDaemon(true);
        taskThread.setName("TryProxyThread");
        taskThread.start();

        try {
            taskThread.join();
        } catch (InterruptedException e) {
            LOG.error("TryProxyThread Task aborted", e);
        }
    }

    public void waitForTryProxyTask(GeniUser user) {
        assert user.getUserAuthorityServer() != null;
        if (user.getUserAuthorityServer().getTestbed().getProxies() == null || user.getUserAuthorityServer().getTestbed().getProxies().isEmpty())
            return;
        waitForTask(new TryProxyTask(user.getUserAuthorityServer().getTestbed().getProxies()));
    }

    public void waitForTryProxyTask(Server authority) {
        if (authority.getTestbed().getProxies() == null || authority.getTestbed().getProxies().isEmpty())
            return;
        waitForTask(new TryProxyTask(authority.getTestbed().getProxies()));
    }

    public Task<Boolean> getTryProxyTask(GeniUser user) {
        assert user.getUserAuthorityServer() != null;
        return new TryProxyTask(user.getUserAuthorityServer().getTestbed().getProxies());
    }


//    /**
//     * check user authority proxy connectivity.
//     */
//    public void tryProxy(Server authority) {
//        tryProxy(authority.getTestbed().getProxies());
//    }

//    /**
//     * check user authority proxy connectivity.
//     */
//    public void tryProxy(List<Proxy> userAuthProxyInfos) {
//        for (Proxy proxyInfo : userAuthProxyInfos) {
//            assert proxyInfo.getProxyType().equals("jFed SSH Proxy");
//            tryProxy(proxyInfo);
//        }
//    }

    public TryProxyTask getTryProxyTask(Server authority) {
        return new TryProxyTask(authority.getTestbed().getProxies());
    }

    /**
     * check user authority proxy connectivity.
     */
    public void tryProxy(Proxy userAuthProxyInfo) {
        assert userAuthProxyInfo != null;
        int portsTested = 0;

        VlanRange portRange = new VlanRange(userAuthProxyInfo.getPortRange());
        for (Integer port : portRange) {
            if (testConnectivity(userAuthProxyInfo.getHostname(), port)) {
                reportReachableProxy(userAuthProxyInfo, port);
            } else {
                reportUnreachableProxy(userAuthProxyInfo, port);
            }

            //test maximum 10 ports for the same proxy
            if (portsTested++ >= MAX_PORTS_TESTED) return;

        }
    }

    public boolean testConnectivity(String hostname, int port) {
        SocketAddress socketAddress = new InetSocketAddress(hostname, port);
        Socket socket = new Socket();
        try {
            //try to connect
            socket.connect(socketAddress, CONNECT_TIMEOUT);

            return socket.isConnected();
        } catch (IOException e) {
            return false;
        } finally {
            try {
                socket.close();
            } catch (IOException ignored) {
            }
        }
    }

    public static class ProxyPort {
        private final Proxy proxyInfo;
        private final int port;

        private ProxyPort(Proxy proxyInfo, int port) {
            assert proxyInfo != null;
            this.port = port;
            this.proxyInfo = proxyInfo;
        }

        public Proxy getProxyInfo() {
            return proxyInfo;
        }

        public String getHostname() {
            return proxyInfo.getHostname();
        }

        public int getPort() {
            return port;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            ProxyPort proxyPort = (ProxyPort) o;

            if (port != proxyPort.port) return false;
            if (!Objects.equals(proxyInfo, proxyPort.proxyInfo)) return false;

            return true;
        }

        @Override
        public int hashCode() {
            int result = proxyInfo.hashCode();
            result = 31 * result + port;
            return result;
        }

        @Override
        public String toString() {
            return proxyInfo.getHostname() + ":" + port;
        }
    }

    public class TryProxyTask extends Task<Boolean> {
        private final List<Proxy> userAuthProxyInfos;
        private boolean finished = false;
        private boolean success = false;

        private TryProxyTask(List<Proxy> userAuthProxyInfos) {
            this.userAuthProxyInfos = userAuthProxyInfos;
        }

        public boolean isSuccess() {
            return success;
        }

        public boolean isFinished() {
            return finished;
        }

        @Override
        protected Boolean call() throws Exception {
            success = false;
            int portsTested = 0;

            updateMessage("Initializing proxy test");

            //count the total number of ports to be tested
            int totalPorts = 0;

            List<VlanRange> rangeHelpers = new ArrayList<>();
            for (Proxy pi : userAuthProxyInfos) {
                VlanRange portRange = new VlanRange(pi.getPortRange());
                totalPorts += Math.min(portRange.size(), MAX_PORTS_TESTED);
                rangeHelpers.add(portRange);
            }

            resetProxyReachabilityCache();
            int totalPortsTested = 0;

            for (int i = 0; i < userAuthProxyInfos.size(); i++) {
                Proxy userAuthProxyInfo = userAuthProxyInfos.get(i);
                assert userAuthProxyInfo != null;
                VlanRange portRange = rangeHelpers.get(i);

                assert Objects.equals(userAuthProxyInfo.getName(), "jFed proxy") &&
                        Objects.equals(userAuthProxyInfo.getType(), "SSH");

                for (Integer port : portRange) {
                    //test maximum 10 ports for the same proxy
                    if (portsTested >= MAX_PORTS_TESTED)
                        break;

                    LOG.info("Testing proxy " + userAuthProxyInfo.getHostname() + ":" + port);
                    updateMessage(String.format("Testing proxy %s:%d", userAuthProxyInfo.getHostname(), port));

                    if (testConnectivity(userAuthProxyInfo.getHostname(), port)) {
                        LOG.info("-> proxy reachable " + userAuthProxyInfo.getHostname() + ":" + port);
                        reportReachableProxy(userAuthProxyInfo, port);
                        success = true;
                    } else {
                        reportUnreachableProxy(userAuthProxyInfo, port);
                        LOG.info("-> proxy unreachable " + userAuthProxyInfo.getHostname() + ":" + port);
                    }

                    totalPortsTested++;
                    updateProgress(totalPortsTested, totalPorts);

                    portsTested++;
                }

            }
            updateMessage("Finished testing proxies.");
            finished = true;
            return success;
        }
    }
}
