package com.codahale.metrics.graphite;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.SocketFactory;

import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * A client to a Carbon server that sends all metrics after they have been pickled in configurable sized batches
 */
public class PickledGraphite implements GraphiteSender {

    static class MetricTuple {
        String name;
        long timestamp;
        String value;

        MetricTuple(String name, long timestamp, String value) {
            this.name = name;
            this.timestamp = timestamp;
            this.value = value;
        }
    }

    /**
     * Minimally necessary pickle opcodes.
     */
    private static final char
            MARK = '(',
            STOP = '.',
            LONG = 'L',
            STRING = 'S',
            APPEND = 'a',
            LIST = 'l',
            TUPLE = 't',
            QUOTE = '\'',
            LF = '\n';

    private static final Logger LOGGER = LoggerFactory.getLogger(PickledGraphite.class);
    private final static int DEFAULT_BATCH_SIZE = 100;

    private int batchSize;
    // graphite expects a python-pickled list of nested tuples.
    private List<MetricTuple> metrics = new ArrayList<>();

    private final String hostname;
    private final int port;
    private final InetSocketAddress address;
    private final SocketFactory socketFactory;
    private final Charset charset;

    private Socket socket;
    private Writer writer;
    private int failures;

    /**
     * Creates a new client which connects to the given address using the default {@link SocketFactory}. This defaults
     * to a batchSize of 100
     *
     * @param address the address of the Carbon server
     */
    public PickledGraphite(InetSocketAddress address) {
        this(address, DEFAULT_BATCH_SIZE);
    }

    /**
     * Creates a new client which connects to the given address using the default {@link SocketFactory}.
     *
     * @param address   the address of the Carbon server
     * @param batchSize how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(InetSocketAddress address, int batchSize) {
        this(address, SocketFactory.getDefault(), batchSize);
    }

    /**
     * Creates a new client which connects to the given address and socket factory.
     *
     * @param address       the address of the Carbon server
     * @param socketFactory the socket factory
     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(InetSocketAddress address, SocketFactory socketFactory, int batchSize) {
        this(address, socketFactory, UTF_8, batchSize);
    }

    /**
     * Creates a new client which connects to the given address and socket factory using the given character set.
     *
     * @param address       the address of the Carbon server
     * @param socketFactory the socket factory
     * @param charset       the character set used by the server
     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(InetSocketAddress address, SocketFactory socketFactory, Charset charset, int batchSize) {
        this.address = address;
        this.hostname = null;
        this.port = -1;
        this.socketFactory = socketFactory;
        this.charset = charset;
        this.batchSize = batchSize;
    }

    /**
     * Creates a new client which connects to the given address using the default {@link SocketFactory}. This defaults
     * to a batchSize of 100
     *
     * @param hostname the hostname of the Carbon server
     * @param port     the port of the Carbon server
     */
    public PickledGraphite(String hostname, int port) {
        this(hostname, port, DEFAULT_BATCH_SIZE);
    }

    /**
     * Creates a new client which connects to the given address using the default {@link SocketFactory}.
     *
     * @param hostname  the hostname of the Carbon server
     * @param port      the port of the Carbon server
     * @param batchSize how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(String hostname, int port, int batchSize) {
        this(hostname, port, SocketFactory.getDefault(), batchSize);
    }

    /**
     * Creates a new client which connects to the given address and socket factory.
     *
     * @param hostname      the hostname of the Carbon server
     * @param port          the port of the Carbon server
     * @param socketFactory the socket factory
     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(String hostname, int port, SocketFactory socketFactory, int batchSize) {
        this(hostname, port, socketFactory, UTF_8, batchSize);
    }

    /**
     * Creates a new client which connects to the given address and socket factory using the given character set.
     *
     * @param hostname      the hostname of the Carbon server
     * @param port          the port of the Carbon server
     * @param socketFactory the socket factory
     * @param charset       the character set used by the server
     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
     */
    public PickledGraphite(String hostname, int port, SocketFactory socketFactory, Charset charset, int batchSize) {
        this.address = null;
        this.hostname = hostname;
        this.port = port;
        this.socketFactory = socketFactory;
        this.charset = charset;
        this.batchSize = batchSize;
    }

    @Override
    public void connect() throws IllegalStateException, IOException {
        if (isConnected()) {
            throw new IllegalStateException("Already connected");
        }
        InetSocketAddress address = this.address;
        if (address == null) {
            address = new InetSocketAddress(hostname, port);
        }
        if (address.getAddress() == null) {
            throw new UnknownHostException(address.getHostName());
        }

        this.socket = socketFactory.createSocket(address.getAddress(), address.getPort());
        this.writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), charset));
    }

    @Override
    public boolean isConnected() {
        return socket != null && socket.isConnected() && !socket.isClosed();
    }

    /**
     * Convert the metric to a python tuple of the form:
     * <p>
     * (timestamp, (name, value))
     * <p>
     * And add it to the list of metrics. If we reach the batch size, write them out.
     *
     * @param name      the name of the metric
     * @param value     the value of the metric
     * @param timestamp the timestamp of the metric
     * @throws IOException if there was an error sending the metric
     */
    @Override
    public void send(String name, String value, long timestamp) throws IOException {
        metrics.add(new MetricTuple(sanitize(name), timestamp, sanitize(value)));

        if (metrics.size() >= batchSize) {
            writeMetrics();
        }
    }

    @Override
    public void flush() throws IOException {
        writeMetrics();
        if (writer != null) {
            writer.flush();
        }
    }

    @Override
    public void close() throws IOException {
        try {
            flush();
            if (writer != null) {
                writer.close();
            }
        } catch (IOException ex) {
            if (socket != null) {
                socket.close();
            }
        } finally {
            this.socket = null;
            this.writer = null;
        }
    }

    @Override
    public int getFailures() {
        return failures;
    }

    /**
     * 1. Run the pickler script to package all the pending metrics into a single message
     * 2. Send the message to graphite
     * 3. Clear out the list of metrics
     */
    private void writeMetrics() throws IOException {
        if (metrics.size() > 0) {
            try {
                byte[] payload = pickleMetrics(metrics);
                byte[] header = ByteBuffer.allocate(4).putInt(payload.length).array();

                @SuppressWarnings("resource")
                OutputStream outputStream = socket.getOutputStream();
                outputStream.write(header);
                outputStream.write(payload);
                outputStream.flush();

                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug("Wrote {} metrics", metrics.size());
                }
            } catch (IOException e) {
                this.failures++;
                throw e;
            } finally {
                // if there was an error, we might miss some data. for now, drop those on the floor and
                // try to keep going.
                metrics.clear();
            }

        }
    }

    /**
     * See: http://readthedocs.org/docs/graphite/en/1.0/feeding-carbon.html
     *
     * @throws IOException shouldn't happen because we write to memory.
     */
    byte[] pickleMetrics(List<MetricTuple> metrics) throws IOException {
        // Extremely rough estimate of 75 bytes per message
        ByteArrayOutputStream out = new ByteArrayOutputStream(metrics.size() * 75);
        Writer pickled = new OutputStreamWriter(out, charset);

        pickled.append(MARK);
        pickled.append(LIST);

        for (MetricTuple tuple : metrics) {
            // start the outer tuple
            pickled.append(MARK);

            // the metric name is a string.
            pickled.append(STRING);
            // the single quotes are to match python's repr("abcd")
            pickled.append(QUOTE);
            pickled.append(tuple.name);
            pickled.append(QUOTE);
            pickled.append(LF);

            // start the inner tuple
            pickled.append(MARK);

            // timestamp is a long
            pickled.append(LONG);
            pickled.append(Long.toString(tuple.timestamp));
            // the trailing L is to match python's repr(long(1234))
            pickled.append(LONG);
            pickled.append(LF);

            // and the value is a string.
            pickled.append(STRING);
            pickled.append(QUOTE);
            pickled.append(tuple.value);
            pickled.append(QUOTE);
            pickled.append(LF);

            pickled.append(TUPLE); // inner close
            pickled.append(TUPLE); // outer close

            pickled.append(APPEND);
        }

        // every pickle ends with STOP
        pickled.append(STOP);

        pickled.flush();

        return out.toByteArray();
    }

    protected String sanitize(String s) {
        return GraphiteSanitize.sanitize(s);
    }

}
