package org.apache.hadoop.hive.cassandra;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

import org.apache.cassandra.thrift.Cassandra;
import org.apache.cassandra.thrift.CfDef;
import org.apache.cassandra.thrift.InvalidRequestException;
import org.apache.cassandra.thrift.KsDef;
import org.apache.cassandra.thrift.SchemaDisagreementException;
import org.apache.cassandra.thrift.TimedOutException;
import org.apache.cassandra.thrift.TokenRange;
import org.apache.cassandra.thrift.UnavailableException;
import org.apache.log4j.Logger;
import org.apache.thrift.TException;
import org.apache.thrift.transport.TFramedTransport;
import org.apache.thrift.transport.TSocket;
import org.apache.thrift.transport.TTransport;
import org.apache.thrift.transport.TTransportException;

/**
 * A proxy client connects to cassandra backend server.
 *
 */
public class CassandraProxyClient implements java.lang.reflect.InvocationHandler {

  private static final Logger logger = Logger.getLogger(CassandraProxyClient.class);

  /**
   * The initial host to create the proxy client.
   */
  private final String host;
  private final int port;

  /**
   * The last successfully connected server.
   */
  private String lastUsedHost;
  /**
   * Last time the ring was checked.
   */
  private long lastPoolCheck;

  /**
   * Cassandra thrift client.
   */
  private CassandraClientHolder clientHolder;

  /**
   * The key space to get the ring information from.
   */
  private String ringKs;

  /**
   * Option to choose the next server from the ring. Default is RoundRobin unless
   * specified by the client to choose randomizer.
   */
  private RingConnOption nextServerGen;

  /**
   * Maximum number of attempts when connection is lost.
   */
  private final int maxAttempts = 10;

  /**
   * Construct a proxy connection.
   *
   * @param host
   *          cassandra host
   * @param port
   *          cassandra port
   * @param framed
   *          true to used framed connection
   * @param randomizeConnections
   *          true if randomly choosing a server when connection fails; false to use round-robin
   *          mechanism
   * @return a Brisk Client Interface
   * @throws IOException
   */
  public CassandraProxyClient(String host, int port, boolean framed, boolean randomizeConnections)
      throws CassandraException {
    this.host = host;
    this.port = port;
    this.lastUsedHost = host;
    this.lastPoolCheck = 0;

    // If randomized to choose a connection, initialize the random generator.
    if (randomizeConnections) {
      nextServerGen = new RandomizerOption();
    } else {
      nextServerGen = new RoundRobinOption();
    }

    initializeConnection();
  }

  /**
   * Return a handle to the proxied connection
   * @return
   */
  public Cassandra.Iface getProxyConnection() {
      return (Cassandra.Iface) java.lang.reflect.Proxy.newProxyInstance(Cassandra.Client.class
              .getClassLoader(),Cassandra.Client.class.getInterfaces(), this);
  }

  /**
   * Delegates to close of {@link CassandraClientHolder#close()}
   */
  public void close() {
      if (clientHolder != null)
      {
          clientHolder.close();
      }
  }

  /**
   * Create connection to a given host.
   *
   * @param host
   *          cassandra host
   * @return cassandra thrift client
   * @throws CassandraException
   *           error
   */
  private CassandraClientHolder createConnection(String host) throws CassandraException {
    TSocket socket = new TSocket(host, port);
    TTransport trans = new TFramedTransport(socket);

    CassandraClientHolder ch = new CassandraClientHolder(trans);

    return ch;
  }



  /**
   * Initialize the cassandra connection with the initial given cassandra host.
   * Create a temporary keyspace if no one exists. Otherwise, choose the first non-system
   * keyspace to describe the ring.
   * Initialize the ring.
   *
   * @throws IOException
   */
  private void initializeConnection() throws CassandraException {
    clientHolder = createConnection(host);

    if (logger.isDebugEnabled()) {
      logger.debug("Connected to cassandra at " + host + ":" + port);
    }

    assert clientHolder.isOpen();

    // Find the first keyspace that's not system and assign it to the lastly used keyspace.
    try {
      List<KsDef> allKs = clientHolder.getClient().describe_keyspaces();

      if (allKs.isEmpty() || (allKs.size() == 1 && allKs.get(0).name.equalsIgnoreCase("system"))) {
        allKs.add(createTmpKs());
      }

      for (KsDef ks : allKs) {
        if (!ks.name.equalsIgnoreCase("system")) {
          ringKs = ks.name;
          break;
        }
      }

      // Set the ring keyspace for initialization purpose. This value
      // should be overwritten later by set_keyspace
      clientHolder.setKeyspace(ringKs);
    } catch (InvalidRequestException e) {
      throw new CassandraException(e);
    } catch (TException e) {
      throw new CassandraException(e);
    } catch (SchemaDisagreementException e){
      throw new CassandraException(e);
    }

    checkRing();
  }

  /**
   * Create a temporary keyspace. This will only be called when there is no keyspace except system
   * defined on (new cluster).
   * However we need a keyspace to call describe_ring to get all servers from the ring.
   *
   * @return the temporary keyspace
   * @throws InvalidRequestException
   *           error
   * @throws TException
   *           error
   * @throws SchemaDisagreementException
   * @throws InterruptedException
   *           error
   */
  private KsDef createTmpKs() throws InvalidRequestException, TException, SchemaDisagreementException {

    Map<String, String> stratOpts = new HashMap<String, String>();
    stratOpts.put("replication_factor", "1");

    KsDef tmpKs = new KsDef("proxy_client_ks", "org.apache.cassandra.locator.SimpleStrategy",
        Arrays.asList(new CfDef[] {})).setStrategy_options(stratOpts);

    clientHolder.getClient().system_add_keyspace(tmpKs);

    return tmpKs;
  }

  /**
   * Refresh the server in the ring.
   *
   * @throws TException
   * @throws InvalidRequestException
   *
   * @throws IOException
   */
  private void checkRing() throws CassandraException {
    assert clientHolder != null;

    long now = System.currentTimeMillis();

    if ((now - lastPoolCheck) > 60 * 1000) {
      List<TokenRange> ring;
      try {
        ring = clientHolder.getClient().describe_ring(ringKs);
      } catch (InvalidRequestException e) {
        throw new CassandraException(e);
      } catch (TException e) {
        throw new CassandraException(e);
      }
      lastPoolCheck = now;
      nextServerGen.resetRing(ring);
    }

  }

  /**
   * Attempt to connect to the next available server.
   * If there is no server in the ring, an exception will be thrown out.
   * If there is no server in the ring that is different from the server used last time,
   * we should try the same server again in case that it recovers.
   * Otherwise, try to connect to a different server.
   *
   * @throws error
   *           when there is no server to connect from the ring.
   */
  private void attemptReconnect() throws CassandraException {
    String endpoint = nextServerGen.getNextServer(lastUsedHost);

    if (endpoint != null) {
      clientHolder = createConnection(endpoint);
      lastUsedHost = endpoint; // Assign the last successfully connected server.
      checkRing(); // Refresh the servers in the ring.
      logger.info("Connected to cassandra at " + endpoint + ":" + port);
    } else {
      clientHolder = createConnection(lastUsedHost);
    }
  }

  public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
    Object result = null;

    int tries = 0;

    while (result == null && tries++ < maxAttempts) {
      try {
        if (clientHolder == null) {
          // Let's try to connect to the next server.
          attemptReconnect();
        }

        if (clientHolder != null && clientHolder.isOpen()) {
          result = m.invoke(clientHolder.getClient(), args);

          if (m.getName().equalsIgnoreCase("set_keyspace") && args != null && args.length == 1) {
            // Keep last known keyspace when set_keyspace is successfully invoked.

            ringKs = (String) args[0];
          }

          return result;
        }
      } catch (CassandraException e) {
        // We are unable to connect to any server in the ring, let's continue trying
        // until we hit the maximum number of attempts.
        if (tries >= maxAttempts) {
          throw e.getCause();
        }
      } catch (InvocationTargetException e) {
        // Error is from cassandra thrift server
        if (e.getTargetException() instanceof UnavailableException ||
                  e.getTargetException() instanceof TimedOutException ||
                  e.getTargetException() instanceof TTransportException) {
          // These errors seem due to not being able to connect the cassandra server.
          // If this is last try quit the program; otherwise keep trying.
          if (tries >= maxAttempts) {
            throw e.getCause();
          }
        } else {
          // The other errors, we should not keep trying.
          throw e.getCause();
        }
      }
    }

    throw new CassandraException("Not able to connect to any server in the ring " + lastUsedHost);
  }

  /**
   * A class to implement the method of getting the next servers from the ring.
   *
   */
  public abstract class RingConnOption {
    protected List<String> servers;

    protected RingConnOption() {

    }

    protected RingConnOption(List<TokenRange> servers) {
      this.servers = getAllServers(servers);
    }

    /**
     * Return the next server from the ring. If there is no server in the ring, throw an exception.
     * If there is only one server in the ring, return the server if it is different from the server
     * tried last time.
     * If there are more than two servers in the ring, return the server that is different from the
     * server tried last time;
     * if there is no server that is different from the server tried last time, return null;
     *
     * @param the
     *          last host used for connection
     * @return next server for connection
     */
    public String getNextServer(String host) throws CassandraException {
      if (!checkServerHealth()) {
        throw new CassandraException("No server is available from the ring.");
      }

      if (servers.size() == 1) {
        if (servers.get(0).equals(host)) {
          return null;
        } else {
          return servers.get(0);
        }
      } else {
        return getServerFromRing(host);
      }
    }

    /**
     * Retrieve the next server from the ring.
     *
     * In the constructor, all servers from the ring are hashed and mapped. Theoretically there
     * should be no duplicated server
     * in the ring.
     *
     * @param host
     *          the last host used for connection
     * @return new server for connection
     */
    protected abstract String getServerFromRing(String host);

    /**
     * Reset the servers in the ring.
     */
    public void resetRing(List<TokenRange> servers) {
      this.servers = getAllServers(servers);
    }

    private List<String> getAllServers(List<TokenRange> input) {
      HashMap<String, Integer> map = new HashMap<String, Integer>(input.size());
      for (TokenRange thisRange : input) {
        List<String> servers = thisRange.rpc_endpoints;
        for (String newServer : servers) {
          map.put(newServer, new Integer(1));
        }
      }

      return new ArrayList<String>(map.keySet());
    }

    /**
     * Check the health of the server ring.
     *
     * @return If there is no server in the ring, return false; Otherwise return true.
     */
    private boolean checkServerHealth() {
      if (servers == null || servers.size() == 0) {
        logger.warn("No cassandra ring information found, no node is available to connect to");
        return false;
      }

      return true;
    }
  }

  /**
   * Randomly choose a server from the ring to connect.
   */
  public class RandomizerOption extends RingConnOption {

    private final Random generator;

    public RandomizerOption() {
      super();
      generator = new Random();
    }

    public RandomizerOption(List<TokenRange> rings) {
      super(rings);
      generator = new Random();
    }

    @Override
    protected String getServerFromRing(String thisHost) {
      String endpoint = thisHost;

      while (!endpoint.equals(thisHost)) {
        int index = generator.nextInt(servers.size());
        endpoint = servers.get(index);
      }

      return endpoint;
    }
  }

  /**
   * Choose a server using round-robin mechanism.
   */
  public class RoundRobinOption extends RingConnOption {
    private int lastUsedIndex;

    public RoundRobinOption() {
      super();
    }

    public RoundRobinOption(List<TokenRange> rings) {
      super(rings);
      lastUsedIndex = 0;
    }

    @Override
    protected String getServerFromRing(String thisHost) {
      String endpoint = thisHost;

      while (!endpoint.equals(thisHost)) {
        lastUsedIndex++;
        // Start from beginning if reaches to the last server in the ring.
        if (lastUsedIndex == servers.size()) {
          lastUsedIndex = 0;
        }

        endpoint = servers.get(lastUsedIndex);
      }

      return endpoint;
    }
  }
}