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

import be.iminds.ilabt.jfed.fedmon.webapi.service.json.Service;
import be.iminds.ilabt.jfed.lowlevel.GeniUser;
import be.iminds.ilabt.jfed.lowlevel.JFedException;
import be.iminds.ilabt.jfed.lowlevel.ssh_key_info.SshKeyInfo;
import be.iminds.ilabt.jfed.util.KeyUtil;
import com.fasterxml.jackson.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.xml.bind.annotation.*;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * jFedConnection: A connection to a server of a Server
 * <p/>
 * also has some debugging base functionality (may be removed later)
 *
 * SPECIAL:
 *   Each jFed connection is also linked to the pool/factory that created it. (may be null)
 *   This is used so that users of the connection can create a new connection easily when they need to retry a call over a new connection
 *   See https://ibcn-jira.intec.ugent.be/browse/FEDIBBTDEV-815
 */
public abstract class JFedConnection {
    private static final Logger LOG = LoggerFactory.getLogger(JFedConnection.class);

    private static final int CONNECT_TIMEOUT = 5000; //5 seconds
    private static final long CON_EXPIRE_MS = 25 * 1000L; //25 seconds   (because a scenario was seen where cons failed after 30 seconds)

    @Nonnull
    protected final ConnectionConfig connectionConfig;
    @Nonnull
    protected final String serverUrl;
    /**
     * The entire connection is faked for debugging purposes. Nothing really happens
     */
    protected boolean error = false;

    @Nullable
    protected final DebugInfo debugInfo;


    private static final AtomicInteger nextConnectionId = new AtomicInteger();
    private final int connectionId = nextConnectionId.getAndIncrement();
    public int getConnectionId() {
        return connectionId;
    }

    private Date lastCallTime;
    private long connectionExpireMs = CON_EXPIRE_MS;

    /**
     * Access to this variable needs to be synchronized!
     */
    private int userCount = 0;

    //FEDIBBTDEV-815
    @Nullable
    private JFedConnectionFactory connectionFactory;


    /**
     * @throws be.iminds.ilabt.jfed.lowlevel.JFedException if something went wrong while performing the call: either a server-error or an unexpected server response
     */
    protected JFedConnection(@Nonnull ConnectionConfig connectionConfig) {
        this.connectionConfig = connectionConfig;
        this.debugInfo = connectionConfig.getDebugInfo();

        //not that serverUrl CAN be null if this is a fake debugging authority
        if (connectionConfig.getServerUrlString() == null) throw new IllegalArgumentException("Illegal argument: serverURL == null");
        this.serverUrl = connectionConfig.getServerUrlString();
    }

    @Nullable
    public DebugInfo getDebugInfo() {
        return debugInfo;
    }

    @Nonnull
    public String getServerUrl() {
        return serverUrl;
    }

    /**
     * @return the config settings used to make this connection
     */
    @Nonnull
    public ConnectionConfig getConnectionConfig() {
        return connectionConfig;
    }

    public void markError() {
        error = true;
    }

    public boolean isError() {
        return error;
    }

    public ProxyInfo getProxyInfo() {
        return connectionConfig.getProxyInfo();
    }

    /**
     * close and cleanup connection
     */
    public abstract void close();

    /** register that a call has been made on the connection now. */
    public void markCallTime() {
        lastCallTime = new Date();
    }
    public Date getLastCallTime() {
        return lastCallTime;
    }

    /**
     * @return true if the last call on this connection was too long ago and the connection may not be reused anymore.
     *    (or in the future possibly any other reason it should not be reused anymore)
     *
     * See FEDIBBTDEV-763
     * See not in AbstractApi#executeXmlRpcCommand
     * */
    public boolean isExpired() {
        if (isInUse()) return false;
        if (lastCallTime == null) return false;
        Date expireDate = new Date(lastCallTime.getTime() + connectionExpireMs);
        return new Date().after(expireDate);
    }
    /**
     * possibly reduce connectionExpireMs, unless it is already smaller.
     * See FEDIBBTDEV-763
     * See not in AbstractApi#executeXmlRpcCommand
     * */
    public void reduceConnectionExpireMs(long maxConnectionExpireMs) {
        if (maxConnectionExpireMs < connectionExpireMs)
            connectionExpireMs = maxConnectionExpireMs;
    }

    //XmlTransient might be needed for abstract, but I don't know if that is so in this case: @XmlTransient
    @XmlAccessorType(XmlAccessType.NONE)
    @XmlSeeAlso({SshProxyInfo.class})
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonIgnoreProperties("@context")
    public static abstract class ProxyInfo {
        @XmlElement
        protected final String hostname;

        @XmlElement
        protected final int port;


        /**
         * DO NOT USE THIS CONSTRUCTOR
         * <p/>
         * This constructor is provided for JAXB, and should not be used for any other purpose.
         */
        private ProxyInfo() {
            this("localhost", 8888);
        }


        @JsonCreator
        protected ProxyInfo(@JsonProperty("hostname") String hostname,
                            @JsonProperty("port") int port) {
            this.hostname = hostname;
            this.port = port;
        }

        @JsonProperty
        public String getHostname() {
            return hostname;
        }

        @JsonProperty
        public int getPort() {
            return port;
        }

        @Override
        public String toString() {
            return "JFedConnection.ProxyInfo{" +
                    "hostname='" + hostname + '\'' +
                    ", port=" + port +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof ProxyInfo)) return false;

            ProxyInfo proxyInfo = (ProxyInfo) o;

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

            return true;
        }

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

    @XmlRootElement(name = "ssh_proxy_info")
    @XmlAccessorType(XmlAccessType.NONE)
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonIgnoreProperties("@context")
    public static class SshProxyInfo extends ProxyInfo {
        @XmlElement
        protected final String username;

        //privateKey should really never get into the call log info (contains private key)
        //so explicitly stating (with XmlTransient) that privateKey may never be serialized!
        //     Note: XmlAccessorType(XmlAccessType.NONE) would actually have been enough if no @XmlElement is added
        @XmlTransient
        protected final SshKeyInfo sshKeyInfo;

        @XmlElement
        protected final String hostKey;

        /**
         * only for jaxb (immutable) object construction
         */
        @SuppressWarnings("unused")
        private SshProxyInfo() {
            this(null, -1, null, null, null, false);
        }

        @JsonCreator
        public SshProxyInfo(
                @JsonProperty("hostname") String hostname,
                @JsonProperty("port") int port,
                @JsonProperty("username") String username,
                @JsonProperty("sshKeyInfo") SshKeyInfo sshKeyInfo,
                @JsonProperty("hostKey") String hostKey,
                @JsonProperty("limitName") boolean limitName) {
            super(hostname, port);

            this.sshKeyInfo = sshKeyInfo;
            this.hostKey = hostKey;

            if (limitName)
                this.username = limitUsername(username);
            else
                this.username = username;
        }

        public SshProxyInfo(SshProxyInfo o, SshKeyInfo sshKeyInfo){
            this(o.hostname, o.port, o.username, sshKeyInfo, o.hostKey, false);
        }

        @JsonIgnore
        public static String limitUsername(String username) {
            if (username == null || username.length() <= 8)
                return username;
            String shortUsername = username.replaceAll("[_ -]", "").toLowerCase();
            if (shortUsername.length() > 8)
                shortUsername = shortUsername.substring(0, 8);
            return shortUsername;
        }

        @JsonProperty
        public String getUsername() {
            return username;
        }

        @JsonIgnore
        public SshKeyInfo getSshKeyInfo() {
            return sshKeyInfo;
        }

        @JsonProperty
        public String getHostKey() {
            return hostKey;
        }

        @Override
        public String toString() {

            String sshKeyInfoText = "HIDDEN";
            if (sshKeyInfo != null) {
                try {
                    sshKeyInfoText =
                            "{ PrivKey=HIDDEN,PubKey=\"" + KeyUtil.publicKeyToOpenSshAuthorizedKeysFormat(sshKeyInfo.getPublicKey()) + "\" }";
                } catch (UnsupportedOperationException e) {
                    sshKeyInfoText = "CANNOT SHOW: " + e.getMessage();
                }
            }

            return "JFedConnection.SshProxyInfo{" +
                    "hostname='" + hostname + '\'' +
                    ", port=" + port +
                    ", username=" + username +
                    ", sshKeyInfo=" + sshKeyInfoText +
                    ", hostKey=" + hostKey +
                    '}';
        }

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

            SshProxyInfo that = (SshProxyInfo) o;

            if (hostKey != null ? !Objects.equals(hostKey, that.hostKey) : that.hostKey != null) return false;
            if (!Objects.equals(sshKeyInfo, that.sshKeyInfo)) return false;
            if (!Objects.equals(username, that.username)) return false;

            return true;
        }

        @Override
        public int hashCode() {
            int result = super.hashCode();
            result = 31 * result + username.hashCode();
            result = 31 * result + sshKeyInfo.hashCode();
            result = 31 * result + (hostKey != null ? hostKey.hashCode() : 0);
            return result;
        }
    }

    /**
     * Used to add debug info to connection.
     * This info is never really needed (and sometimes is not even applicable to the connection type), but can be used for debugging and information purposes.
     * Therefore, all of this info can always be null.
     *
     * This also stores info about whether to add extra debug logging or not, and whether the connection is a fake connection for debugging
     * */
    public static class DebugInfo {
        //fields with useful information for debugging (may all be null)
        @Nullable
        private final Service service;
        @Nullable
        private final GeniUser user;


        public DebugInfo(@Nullable Service service, @Nullable GeniUser user) {
            this.service = service;
            this.user = user;
        }

        @Nullable
        public Service getService() {
            return service;
        }

        @Nullable
        public GeniUser getUser() {
            return user;
        }


        @Override
        public String toString() {
            return "DebugInfo{" +
                    "auth=" + (service == null ? null : service.getUrn()) +
                    ", user=" + (user == null ? null : user.getUserUrn()) +
                    '}';
        }
    }


    //FEDIBBTDEV-815
    //package local, so that SfaConnectionPool can set and use these
    interface JFedConnectionFactory {
        JFedConnection create() throws JFedException;
    }
    @Nullable
    JFedConnectionFactory getConnectionFactory() {
        return connectionFactory;
    }
    void setConnectionFactory(@Nullable JFedConnectionFactory connectionFactory) {
        this.connectionFactory = connectionFactory;
    }

    //FEDIBBTDEV-815
    //allow anyone to get a new connection, with the same parameters as this one
    public JFedConnection getNewConnection() throws JFedException {
        return connectionFactory == null ? null : connectionFactory.create();
    }
    public boolean canDuplicate() {
        return connectionFactory != null;
    }

    /**
     * @return whether the connection is currently in use.
     * */
    public synchronized boolean isInUse() {
        return userCount > 0;
    }
    /**
     * Mark the connection as in use. Note that in use connections can NOT expire!
     *
     * If you call this method, make sure you ALWAYS mark the connection as not in use once you are done with it. Take exceptions into account!
     * */
    public synchronized void markInUse() {
        this.userCount++;
        lastCallTime = new Date();
    }
    /**
     * Mark the connection as not in use. Note that in use connections can NOT expire!
     * */
    public synchronized void markNotInUse() {
        this.userCount--;
        lastCallTime = new Date();
    }


    X509Certificate[] serverCertificates;
    public void registerServerCertficates(X509Certificate[] serverCertificates) {
        if (serverCertificates != null && serverCertificates.length > 0)
            this.serverCertificates = serverCertificates;
    }
    public X509Certificate[] getServerCertificates() {
        return serverCertificates;
    }
}
