/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF licenses this file to You 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.apache.geode.distributed.internal.tcpserver;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import javax.net.ssl.SSLException;

import org.apache.logging.log4j.Logger;

import org.apache.geode.DataSerializer;
import org.apache.geode.cache.UnsupportedVersionException;
import org.apache.geode.distributed.internal.DistributionConfig;
import org.apache.geode.internal.Version;
import org.apache.geode.internal.VersionedDataInputStream;
import org.apache.geode.internal.VersionedDataOutputStream;
import org.apache.geode.internal.admin.SSLConfig;
import org.apache.geode.internal.logging.LogService;
import org.apache.geode.internal.net.SSLConfigurationFactory;
import org.apache.geode.internal.net.SocketCreator;
import org.apache.geode.internal.net.SocketCreatorFactory;
import org.apache.geode.internal.security.SecurableCommunicationChannel;

/**
 * <p>
 * Client for the TcpServer component of the Locator.
 * </p>
 *
 * @since GemFire 5.7
 */
public class TcpClient {

  private static final Logger logger = LogService.getLogger();

  private static final int DEFAULT_REQUEST_TIMEOUT = 60 * 2 * 1000;

  private static Map<InetSocketAddress, Short> serverVersions =
      new HashMap<InetSocketAddress, Short>();

  private final SocketCreator socketCreator;

  public TcpClient(DistributionConfig distributionConfig) {
    this(SocketCreatorFactory.setDistributionConfig(distributionConfig)
        .getSocketCreatorForComponent(SecurableCommunicationChannel.LOCATOR));
  }

  /**
   * Constructs a new TcpClient using the default (Locator) SocketCreator. SocketCreatorFactory
   * should be initialized before invoking this method.
   */
  public TcpClient() {
    this(SocketCreatorFactory.getSocketCreatorForComponent(SecurableCommunicationChannel.LOCATOR));
  }

  public TcpClient(Properties properties) {
    SSLConfig sslConfig = SSLConfigurationFactory.getSSLConfigForComponent(properties,
        SecurableCommunicationChannel.LOCATOR);
    this.socketCreator = new SocketCreator(sslConfig);
  }

  /**
   * Constructs a new TcpClient
   *
   * @param socketCreator the SocketCreator to use in communicating with the Locator
   */
  public TcpClient(SocketCreator socketCreator) {
    this.socketCreator = socketCreator;
  }

  /**
   * Stops the Locator running on a given host and port
   */
  public void stop(InetAddress addr, int port) throws java.net.ConnectException {
    try {
      ShutdownRequest request = new ShutdownRequest();
      requestToServer(addr, port, request, DEFAULT_REQUEST_TIMEOUT);
    } catch (java.net.ConnectException ce) {
      // must not be running, rethrow so the caller can handle.
      // In most cases this Exception should be ignored.
      throw ce;
    } catch (Exception ex) {
      logger.error(
          "TcpClient.stop(): exception connecting to locator " + addr + ":" + port + ": " + ex);
    }
  }

  /**
   * Contacts the Locator running on the given host, and port and gets information about it. Two
   * <code>String</code>s are returned: the first string is the working directory of the locator and
   * the second string is the product directory of the locator.
   */
  public String[] getInfo(InetAddress addr, int port) {
    try {
      InfoRequest request = new InfoRequest();
      InfoResponse response =
          (InfoResponse) requestToServer(addr, port, request, DEFAULT_REQUEST_TIMEOUT);
      return response.getInfo();
    } catch (java.net.ConnectException ignore) {
      return null;
    } catch (Exception ex) {
      logger.error(
          "TcpClient.getInfo(): exception connecting to locator " + addr + ":" + port + ": " + ex);
      return null;
    }

  }

  /**
   * Send a request to a Locator and expect a reply
   *
   * @param addr The locator's address
   * @param port The locator's tcp/ip port
   * @param request The request message
   * @param timeout Timeout for sending the message and receiving a reply
   *
   * @return the reply
   *
   * @throws IOException
   * @throws ClassNotFoundException
   */
  public Object requestToServer(InetAddress addr, int port, Object request, int timeout)
      throws IOException, ClassNotFoundException {

    return requestToServer(addr, port, request, timeout, true);
  }

  /**
   * Send a request to a Locator
   *
   * @param addr The locator's address
   * @param port The locator's tcp/ip port
   * @param request The request message
   * @param timeout Timeout for sending the message and receiving a reply
   * @param replyExpected Whether to wait for a reply
   *
   * @return the reply
   *
   * @throws IOException
   * @throws ClassNotFoundException
   */
  public Object requestToServer(InetAddress addr, int port, Object request, int timeout,
      boolean replyExpected) throws IOException, ClassNotFoundException {
    InetSocketAddress ipAddr;
    if (addr == null) {
      ipAddr = new InetSocketAddress(port);
    } else {
      ipAddr = new InetSocketAddress(addr, port); // fix for bug 30810
    }
    return requestToServer(ipAddr, request, timeout, replyExpected);
  }

  /**
   * Send a request to a Locator
   *
   * @param ipAddr The locator's inet socket address
   * @param request The request message
   * @param timeout Timeout for sending the message and receiving a reply
   * @param replyExpected Whether to wait for a reply
   *
   * @return The reply, or null if no reply is expected
   *
   * @throws IOException
   * @throws ClassNotFoundException
   */
  public Object requestToServer(InetSocketAddress ipAddr, Object request, int timeout,
      boolean replyExpected) throws IOException, ClassNotFoundException {
    /*
     * InetSocketAddress ipAddr; if (addr == null) { ipAddr = new InetSocketAddress(port); } else {
     * ipAddr = new InetSocketAddress(addr, port); // fix for bug 30810 }
     */

    long giveupTime = System.currentTimeMillis() + timeout;

    // Get the GemFire version of the TcpServer first, before sending any other request.
    short serverVersion = getServerVersion(ipAddr, timeout).shortValue();

    if (serverVersion > Version.CURRENT_ORDINAL) {
      serverVersion = Version.CURRENT_ORDINAL;
    }

    // establish the old GossipVersion for the server
    int gossipVersion = TcpServer.getCurrentGossipVersion();

    if (Version.GFE_71.compareTo(serverVersion) > 0) {
      gossipVersion = TcpServer.getOldGossipVersion();
    }

    long newTimeout = giveupTime - System.currentTimeMillis();
    if (newTimeout <= 0) {
      return null;
    }

    logger.debug("TcpClient sending {} to {}", request, ipAddr);

    Socket sock =
        socketCreator.connect(ipAddr.getAddress(), ipAddr.getPort(), (int) newTimeout, null, false);
    sock.setSoTimeout((int) newTimeout);
    DataOutputStream out = null;
    try {
      out = new DataOutputStream(sock.getOutputStream());

      if (serverVersion < Version.CURRENT_ORDINAL) {
        out = new VersionedDataOutputStream(out, Version.fromOrdinalNoThrow(serverVersion, false));
      }

      out.writeInt(gossipVersion);
      if (gossipVersion > TcpServer.getOldGossipVersion()) {
        out.writeShort(serverVersion);
      }
      DataSerializer.writeObject(request, out);
      out.flush();

      if (replyExpected) {
        DataInputStream in = new DataInputStream(sock.getInputStream());
        in = new VersionedDataInputStream(in, Version.fromOrdinal(serverVersion, false));
        try {
          Object response = DataSerializer.readObject(in);
          logger.debug("received response: {}", response);
          return response;
        } catch (EOFException ex) {
          logger.debug("requestToServer EOFException ", ex);
          EOFException eof = new EOFException("Locator at " + ipAddr
              + " did not respond. This is normal if the locator was shutdown. If it wasn't check its log for exceptions.");
          eof.initCause(ex);
          throw eof;
        }
      } else {
        return null;
      }
    } catch (UnsupportedVersionException ex) {
      if (logger.isDebugEnabled()) {
        logger
            .debug("Remote TcpServer version: " + serverVersion + " is higher than local version: "
                + Version.CURRENT_ORDINAL + ". This is never expected as remoteVersion");
      }
      return null;
    } finally {
      try {
        if (replyExpected) {
          // Since we've read a response we know that the Locator is finished
          // with the socket and is closing it. Aborting the connection by
          // setting SO_LINGER to zero will clean up the TIME_WAIT socket on
          // the locator's machine.
          if (!sock.isClosed() && !socketCreator.useSSL()) {
            sock.setSoLinger(true, 0);
          }
        }
        sock.close();
      } catch (Exception e) {
        logger.error("Error closing socket ", e);
      }
      if (out != null) {
        out.close();
      }
    }
  }

  private Short getServerVersion(InetSocketAddress ipAddr, int timeout)
      throws IOException, ClassNotFoundException {

    int gossipVersion = TcpServer.getCurrentGossipVersion();
    Short serverVersion = null;

    // Get GemFire version of TcpServer first, before sending any other request.
    synchronized (serverVersions) {
      serverVersion = serverVersions.get(ipAddr);
    }
    if (serverVersion != null) {
      return serverVersion;
    }

    gossipVersion = TcpServer.getOldGossipVersion();

    Socket sock = null;
    try {
      sock = socketCreator.connect(ipAddr.getAddress(), ipAddr.getPort(), timeout, null, false);
      sock.setSoTimeout(timeout);
    } catch (SSLException e) {
      throw new LocatorCancelException("Unable to form SSL connection", e);
    }

    try {
      DataOutputStream out = new DataOutputStream(sock.getOutputStream());
      out = new VersionedDataOutputStream(out, Version.GFE_57);

      out.writeInt(gossipVersion);

      VersionRequest verRequest = new VersionRequest();
      DataSerializer.writeObject(verRequest, out);
      out.flush();

      InputStream inputStream = sock.getInputStream();
      DataInputStream in = new DataInputStream(inputStream);
      in = new VersionedDataInputStream(in, Version.GFE_57);
      try {
        Object readObject = DataSerializer.readObject(in);
        if (!(readObject instanceof VersionResponse)) {
          throw new LocatorCancelException(
              "Unrecognisable response received: This could be the result of trying to connect a non-SSL-enabled client to an SSL-enabled locator.");
        }
        VersionResponse response = (VersionResponse) readObject;
        if (response != null) {
          serverVersion = Short.valueOf(response.getVersionOrdinal());
          synchronized (serverVersions) {
            serverVersions.put(ipAddr, serverVersion);
          }
          return serverVersion;
        }
      } catch (EOFException ex) {
        // old locators will not recognize the version request and will close the connection
      }
    } finally {
      try {
        sock.setSoLinger(true, 0); // initiate an abort on close to shut down the server's socket
        sock.close();
      } catch (Exception e) {
        logger.error("Error closing socket ", e);
      }
    }
    if (logger.isDebugEnabled()) {
      logger.debug("Locator " + ipAddr
          + " did not respond to a request for its version.  I will assume it is using v5.7 for safety.");
    }
    synchronized (serverVersions) {
      serverVersions.put(ipAddr, Version.GFE_57.ordinal());
    }
    return Short.valueOf(Version.GFE_57.ordinal());
  }

  /**
   * Clear static class information concerning Locators. This is used in unit tests. It will force
   * TcpClient to send version-request messages to locators to reestablish knowledge of their
   * communication protocols.
   */
  public static void clearStaticData() {
    synchronized (serverVersions) {
      serverVersions.clear();
    }
  }

}
