package com.datastax.logging.appender;

import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;

import com.datastax.driver.core.*;
import org.apache.log4j.AppenderSkeleton;
import org.apache.log4j.Level;
import org.apache.log4j.LogManager;
import org.apache.log4j.helpers.LogLog;
import org.apache.log4j.spi.LocationInfo;
import org.apache.log4j.spi.LoggingEvent;

import org.codehaus.jackson.map.ObjectMapper;

import com.datastax.driver.core.policies.RoundRobinPolicy;

import com.google.common.base.Joiner;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;

/**
 * Main class that uses Cassandra to store log entries into.
 * 
 */
public class CassandraAppender extends AppenderSkeleton
{
    // Cassandra configuration
    private String hosts = "localhost";
    private int port = 9042; //for the binary protocol, 9160 is default for thrift
    private String username = "";
    private String password = "";
    private static final String ip = getIP();
    private static final String hostname = getHostName();

    // Encryption.  sslOptions and authProviderOptions are JSON maps requiring Jackson
    private static final ObjectMapper jsonMapper = new ObjectMapper();
    private Map<String, String> sslOptions = null;
    private Map<String, String> authProviderOptions = null;

    // Keyspace/ColumnFamily information
    private String keyspaceName = "Logging";
	private String columnFamily = "log_entries";
	private String appName = "default";
    private String replication = "{ 'class' : 'SimpleStrategy', 'replication_factor' : 1 }";
    private ConsistencyLevel consistencyLevelWrite = ConsistencyLevel.ONE;

    // CF column names
    public static final String ID = "key";
    public static final String HOST_IP = "host_ip";
    public static final String HOST_NAME = "host_name";
    public static final String APP_NAME = "app_name";
    public static final String LOGGER_NAME = "logger_name";
    public static final String LEVEL = "level";
    public static final String CLASS_NAME = "class_name";
    public static final String FILE_NAME = "file_name";
    public static final String LINE_NUMBER = "line_number";
    public static final String METHOD_NAME = "method_name";
    public static final String MESSAGE = "message";
    public static final String NDC = "ndc";
    public static final String APP_START_TIME = "app_start_time";
    public static final String THREAD_NAME = "thread_name";
    public static final String THROWABLE_STR = "throwable_str_rep";
    public static final String TIMESTAMP = "log_timestamp";

    // session state
    private PreparedStatement statement;
    private volatile boolean initialized = false;
    private volatile boolean initializationFailed = false;
    private Cluster cluster;
    private Session session;

    public CassandraAppender()
    {
		LogLog.debug("Creating CassandraAppender");
	}

    /**
     * {@inheritDoc}
     */
    @Override
    protected void append(LoggingEvent event)
    {
        // We have to defer initialization of the client because TTransportFactory
        // references some Hadoop classes which can't safely be used until the logging
        // infrastructure is fully set up. If we attempt to initialize the client
        // earlier, it causes NPE's from the constructor of org.apache.hadoop.conf.Configuration.
        if (!initialized)
            initClient();
        if (!initializationFailed)
            createAndExecuteQuery(event);
    }

    //Connect to cassandra, then setup the schema and preprocessed statement
    private synchronized void initClient()
    {
        // We should be able to go without an Atomic variable here.  There are two potential problems:
        // 1. Multiple threads read intialized=false and call init client.  However, the method is
        //    synchronized so only one will get the lock first, and the others will drop out here.
        // 2. One thread reads initialized=true before initClient finishes.  This also should not
        //    happen as the lock should include a memory barrier.
        if (initialized || initializationFailed)
            return;

		// Just while we initialise the client, we must temporarily
		// disable all logging or else we get into an infinite loop
		Level globalThreshold = LogManager.getLoggerRepository().getThreshold();
		LogManager.getLoggerRepository().setThreshold(Level.OFF);

		try
        {
            Cluster.Builder builder = Cluster.builder()
                                             .addContactPoints(hosts.split(",\\s*"))
                                             .withPort(port)
                                             .withLoadBalancingPolicy(new RoundRobinPolicy());

            // Kerberos provides authentication anyway, so a username and password are superfluous.  SSL
            // is compatible with either.
            boolean passwordAuthentication = !password.equals("") || !username.equals("");
            if (authProviderOptions != null && passwordAuthentication)
                throw new IllegalArgumentException("Authentication via both Cassandra usernames and Kerberos " +
                                                   "requested.");

            // Encryption
            if (authProviderOptions != null)
                builder = builder.withAuthProvider(getAuthProvider());
            if (sslOptions != null)
                builder = builder.withSSL(getSslOptions());
            if (passwordAuthentication)
                builder = builder.withCredentials(username, password);

            cluster = builder.build();
		    session = cluster.connect();
            setupSchema();
            setupStatement();
		}
        catch (Exception e)
        {
		    LogLog.error("Error ", e);
			errorHandler.error("Error setting up cassandra logging schema: " + e);

            //If the user misconfigures the port or something, don't keep failing.
            initializationFailed = true;
		}
        finally
        {
            //Always reenable logging
            LogManager.getLoggerRepository().setThreshold(globalThreshold);
            initialized = true;
		}
	}


    /**
     * Create Keyspace and CF if they do not exist.
     */
    private void setupSchema() throws IOException
    {
        //Create keyspace if necessary
        String ksQuery = String.format("CREATE KEYSPACE IF NOT EXISTS \"%s\" WITH REPLICATION = %s;",
                                       keyspaceName, replication);
        session.execute(ksQuery);

        //Create table if necessary
        String cfQuery =  String.format("CREATE TABLE IF NOT EXISTS \"%s\".\"%s\" (%s UUID PRIMARY KEY, " +
                                        "%s text, %s bigint, %s text, %s text, %s text, %s text, %s text," +
                                        "%s text, %s text, %s bigint, %s text, %s text, %s text, %s text," +
                                        "%s text);",
                                        keyspaceName, columnFamily, ID, APP_NAME, APP_START_TIME, CLASS_NAME,
                                        FILE_NAME, HOST_IP, HOST_NAME, LEVEL, LINE_NUMBER, METHOD_NAME,
                                        TIMESTAMP, LOGGER_NAME, MESSAGE, NDC, THREAD_NAME, THROWABLE_STR);
        session.execute(cfQuery);
    }

    /**
     * Setup and preprocess our insert query, so that we can just bind values and send them over the binary protocol
     */
    private void setupStatement()
    {
        //Preprocess our append statement
        String insertQuery = String.format("INSERT INTO \"%s\".\"%s\" " +
                                           "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " +
                                           "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); ",
                                           keyspaceName, columnFamily, ID, APP_NAME, HOST_IP, HOST_NAME, LOGGER_NAME,
                                           LEVEL, CLASS_NAME, FILE_NAME, LINE_NUMBER, METHOD_NAME, MESSAGE, NDC,
                                           APP_START_TIME, THREAD_NAME, THROWABLE_STR, TIMESTAMP);

        statement = session.prepare(insertQuery);
        statement.setConsistencyLevel(ConsistencyLevel.valueOf(consistencyLevelWrite.toString()));
    }

    /**
     * Send one logging event to Cassandra.  We just bind the new values into the preprocessed query
     * built by setupStatement
     */
    private void createAndExecuteQuery(LoggingEvent event)
    {
		BoundStatement bound = new BoundStatement(statement);

        // A primary key combination of timestamp/hostname/threadname should be unique as long as the thread names
        // are set, but would not be backwards compatible.  Do we care?
        bound.setUUID(0, UUID.randomUUID());

        bound.setString(1, appName);
        bound.setString(2, ip);
        bound.setString(3, hostname);
        bound.setString(4, event.getLoggerName());
        bound.setString(5, event.getLevel().toString());

        LocationInfo locInfo = event.getLocationInformation();
        if (locInfo != null) {
            bound.setString(6, locInfo.getClassName());
            bound.setString(7, locInfo.getFileName());
            bound.setString(8, locInfo.getLineNumber());
            bound.setString(9, locInfo.getMethodName());
        }

        bound.setString(10, event.getRenderedMessage());
        bound.setString(11, event.getNDC());
        bound.setLong(12, new Long(LoggingEvent.getStartTime()));
        bound.setString(13, event.getThreadName());

        String[] throwableStrs = event.getThrowableStrRep();
        bound.setString(14, throwableStrs == null ? null : Joiner.on(", ").join(throwableStrs));

        bound.setLong(15, new Long(event.getTimeStamp()));
        session.execute(bound);
    }

    /**
     * {@inheritDoc}
     */
    public void close()
    {
        session.closeAsync();
        cluster.closeAsync();
    }

    /**
     * {@inheritDoc}
     *
     * @see org.apache.log4j.Appender#requiresLayout()
     */
    public boolean requiresLayout()
    {
        return false;
    }

    /**
     * Called once all the options have been set. Starts listening for clients
     * on the specified socket.
     */
    public void activateOptions()
    {
        // reset();
    }

    //
    //Boilerplate from here on out
    //

    public String getKeyspaceName()
    {
		return keyspaceName;
	}

	public void setKeyspaceName(String keyspaceName)
    {
		this.keyspaceName = keyspaceName;
	}

	public String getHosts()
    {
		return hosts;
	}

	public void setHosts(String hosts)
    {
		this.hosts = hosts;
	}

	public int getPort()
    {
		return port;
	}

	public void setPort(int port)
    {
		this.port = port;
	}

	public String getUsername()
    {
		return username;
	}

	public void setUsername(String username)
    {
		this.username = unescape(username);
	}

	public String getPassword()
    {
		return password;
	}

	public void setPassword(String password)
    {
		this.password = unescape(password);
	}

	public String getColumnFamily()
    {
		return columnFamily;
	}

	public void setColumnFamily(String columnFamily)
    {
		this.columnFamily = columnFamily;
	}

	public String getReplication()
    {
		return replication;
	}

	public void setReplication(String strategy)
    {
		replication = unescape(strategy);
	}

    private Map<String, String> parseJsonMap(String options, String type) throws Exception
    {
        if (options == null)
            throw new IllegalArgumentException(type + "Options can't be null.");

        return jsonMapper.readValue(unescape(options), new TreeMap<String, String>().getClass());
    }

    public void setAuthProviderOptions(String newOptions) throws Exception
    {
        authProviderOptions = parseJsonMap(newOptions, "authProvider");
    }

    public void setSslOptions(String newOptions) throws Exception
    {
        sslOptions = parseJsonMap(newOptions, "Ssl");
    }

	public String getConsistencyLevelWrite()
    {
		return consistencyLevelWrite.toString();
	}

	public void setConsistencyLevelWrite(String consistencyLevelWrite)
    {
		try {
			this.consistencyLevelWrite = ConsistencyLevel.valueOf(unescape(consistencyLevelWrite));
		}
        catch (IllegalArgumentException e) {
			throw new IllegalArgumentException("Consistency level " + consistencyLevelWrite
					+ " wasn't found. Available levels: " + Joiner.on(", ").join(ConsistencyLevel.values()));
		}
	}


    public String getAppName()
    {
		return appName;
	}

	public void setAppName(String appName)
    {
		this.appName = appName;
	}

	private static String getHostName()
    {
		String hostname = "unknown";

		try {
			InetAddress addr = InetAddress.getLocalHost();
			hostname = addr.getHostName();
		} catch (Throwable t) {

		}
		return hostname;
	}

	private static String getIP()
    {
		String ip = "unknown";

		try {
			InetAddress addr = InetAddress.getLocalHost();
			ip = addr.getHostAddress();
		} catch (Throwable t) {

		}
		return ip;
	}

	/**
	 * Strips leading and trailing '"' characters
	 * 
	 * @param b
	 *            - string to unescape
	 * @return String - unexspaced string
	 */
	private static String unescape(String b)
    {
		if (b.charAt(0) == '\"' && b.charAt(b.length() - 1) == '\"')
			b = b.substring(1, b.length() - 1);
		return b;
	}

    // Create an SSLContext (a container for a keystore and a truststore and their associated options)
    // Assumes sslOptions map is not null
    private SSLOptions getSslOptions() throws Exception
    {
        // init trust store
        TrustManagerFactory tmf = null;
        String truststorePath = sslOptions.get("ssl.truststore");
        String truststorePassword = sslOptions.get("ssl.truststore.password");
        if (truststorePath != null && truststorePassword != null)
        {
            FileInputStream tsf = new FileInputStream(truststorePath);
            KeyStore ts = KeyStore.getInstance("JKS");
            ts.load(tsf, truststorePassword.toCharArray());
            tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
            tmf.init(ts);
        }

        // init key store
        KeyManagerFactory kmf = null;
        String keystorePath = sslOptions.get("ssl.keystore");
        String keystorePassword = sslOptions.get("ssl.keystore.password");
        if (keystorePath != null && keystorePassword != null)
        {
            FileInputStream ksf = new FileInputStream(keystorePath);
            KeyStore ks = KeyStore.getInstance("JKS");
            ks.load(ksf, keystorePassword.toCharArray());
            kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
            kmf.init(ks, keystorePassword.toCharArray());

        }

        // init cipher suites
        String[] ciphers = SSLOptions.DEFAULT_SSL_CIPHER_SUITES;
        if (sslOptions.containsKey("ssl.ciphersuites"))
            ciphers = sslOptions.get("ssl.ciphersuits").split(",\\s*");

        SSLContext ctx = SSLContext.getInstance("SSL");
        ctx.init(kmf == null ? null : kmf.getKeyManagers(),
                 tmf == null ? null : tmf.getTrustManagers(),
                 new SecureRandom());

        return new SSLOptions(ctx, ciphers);
    }

    // Load a custom AuthProvider class dynamically.
    public AuthProvider getAuthProvider() throws Exception
    {
        ClassLoader cl = ClassLoader.getSystemClassLoader();

        if(!authProviderOptions.containsKey("auth.class"))
            throw new IllegalArgumentException("authProvider map does not include auth.class.");
        Class dap = cl.loadClass(authProviderOptions.get("auth.class"));

        // Perhaps this should be a factory, but it seems easy enough to just have a single string parameter
        // which can be encoded however, e.g. another JSON map
        if(authProviderOptions.containsKey("auth.options"))
            return (AuthProvider)dap.getConstructor(String.class).newInstance(authProviderOptions.get("auth.options"));
        else
            return (AuthProvider)dap.newInstance();
    }
}