package tc.oc.api.queue;

import java.io.IOException;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;

import com.google.common.base.Charsets;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.gson.Gson;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Address;
import com.rabbitmq.client.BasicProperties;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import java.time.Duration;
import tc.oc.api.connectable.Connectable;
import tc.oc.api.config.ApiConstants;
import tc.oc.api.message.Message;
import tc.oc.api.message.MessageRegistry;
import tc.oc.api.message.types.ModelMessage;
import tc.oc.api.model.IdFactory;
import tc.oc.api.model.ModelRegistry;
import tc.oc.commons.core.concurrent.ExecutorUtils;
import tc.oc.commons.core.logging.Loggers;
import tc.oc.commons.core.reflect.Types;
import tc.oc.commons.core.util.Joiners;
import tc.oc.commons.core.util.MapUtils;

import static com.google.common.base.Preconditions.checkNotNull;

@Singleton
public class QueueClient implements Connectable {

    private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);

    protected static final Metadata DEFAULT_PROPERTIES = new Metadata.Builder()
        .appId("ocn")
        .contentType("application/json")
        .contentEncoding("utf-8")
        .protocolVersion(ApiConstants.PROTOCOL_VERSION)
        .build();

    protected final Logger logger;

    private final QueueClientConfiguration config;
    private final @Nullable ThreadFactory threadFactory;
    private final Gson gson;
    private final MessageRegistry messageRegistry;
    private final ModelRegistry modelRegistry;
    private final IdFactory idFactory;

    private Connection connection;
    private Channel channel;
    private final ListeningExecutorService executorService;

    @Inject QueueClient(Loggers loggers, QueueClientConfiguration config, Gson gson, MessageRegistry messageRegistry, ModelRegistry modelRegistry, IdFactory idFactory) {
        this.modelRegistry = modelRegistry;
        this.logger = loggers.get(getClass());
        this.idFactory = idFactory;
        this.config = checkNotNull(config, "config");
        this.gson = checkNotNull(gson, "GSON");
        this.messageRegistry = checkNotNull(messageRegistry, "message registry");
        this.threadFactory = new ThreadFactoryBuilder().setNameFormat("API AMQP Executor").build();

        if (config.getThreads() > 0) {
            this.executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(config.getThreads()));
        } else {
            this.executorService = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool(threadFactory));
        }
    }

    public Logger getLogger() {
        return logger;
    }

    public Channel getChannel() {
        if(channel == null) {
            throw new IllegalStateException("QueueClient is not connected");
        }
        return channel;
    }

    private static Metadata cloneProperties(Metadata properties) {
        try {
            return (Metadata) properties.clone();
        } catch(CloneNotSupportedException e) {
            throw new IllegalStateException(e);
        }
    }

    private static void mergeProperties(Metadata to, BasicProperties from) {
        if(from.getMessageId() != null) to.setMessageId(from.getMessageId());
        if(from.getDeliveryMode() != null) to.setDeliveryMode(from.getDeliveryMode());
        if(from.getExpiration() != null) to.setExpiration(from.getExpiration());
        if(from.getCorrelationId() != null) to.setCorrelationId(from.getCorrelationId());
        if(from.getReplyTo() != null) to.setReplyTo(from.getReplyTo());

        final Map<String, Object> headers = from.getHeaders();
        if(headers != null && !headers.isEmpty()) {
            to.setHeaders(MapUtils.merge(to.getHeaders(), headers));
        }
    }

    public Metadata getProperties(Message message, @Nullable BasicProperties properties) {
        Metadata amqp = cloneProperties(DEFAULT_PROPERTIES);

        amqp.setMessageId(idFactory.newId());
        amqp.setTimestamp(new Date());

        amqp.setType(messageRegistry.typeName(message.getClass()));
        if(message instanceof ModelMessage) {
            amqp.setHeaders(MapUtils.merge(amqp.getHeaders(),
                                           Metadata.MODEL_NAME,
                                           modelRegistry.meta(((ModelMessage) message).model()).name()));
        }

        MessageDefaults.ExpirationMillis expiration = Types.inheritableAnnotation(message.getClass(), MessageDefaults.ExpirationMillis.class);
        if(expiration != null) {
            amqp.setExpiration(String.valueOf(expiration.value()));
        }

        MessageDefaults.Persistent persistent = Types.inheritableAnnotation(message.getClass(), MessageDefaults.Persistent.class);
        if(persistent != null) {
            amqp.setDeliveryMode(persistent.value() ? 2 : 1);
        }

        if(properties != null) mergeProperties(amqp, properties);

        return amqp;
    }

    public Publish getPublish(Message message, @Nullable Publish publish) {
        if(publish == null) {
            publish = Publish.DEFAULT;
        }

        if("".equals(publish.routingKey())) {
            MessageDefaults.RoutingKey routingKey = Types.inheritableAnnotation(message.getClass(), MessageDefaults.RoutingKey.class);
            if(routingKey != null) {
                publish = new Publish(routingKey.value(), publish.mandatory(), publish.immediate());
            }
        }

        return publish;
    }

    private void publish(Exchange exchange, String payload, AMQP.BasicProperties properties, @Nullable Publish publish) {
        if(logger.isLoggable(Level.FINE)) {
            logger.fine("Publishing to exchange " + exchange.name() +
                        " with routing key " + publish.routingKey() +
                        " and properties " + properties +
                        ":\n" + payload);
        }

        try {
            getChannel().basicPublish(exchange.name(),
                                      publish.routingKey(),
                                      publish.mandatory(),
                                      publish.immediate(),
                                      properties,
                                      payload.getBytes(Charsets.UTF_8));
        } catch(IOException e) {
            logger.log(Level.SEVERE,
                       "Failed to publish message of type " + properties.getType() +
                       " to exchange '" + exchange + "' with routing key '" + publish.routingKey() + "'",
                       e);
        }
    }

    public void publishSync(Exchange exchange, Message message, @Nullable BasicProperties properties, @Nullable Publish publish) {
        publish(exchange, gson.toJson(message), getProperties(message, properties), Publish.forMessage(message, publish));
    }

    public ListenableFuture<?> publishAsync(final Exchange exchange, final Message message, final @Nullable BasicProperties properties, final @Nullable Publish publish) {
        // NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe
        final String payload = gson.toJson(message);
        final AMQP.BasicProperties finalProperties = getProperties(message, properties);
        final Publish finalPublish = Publish.forMessage(message, publish);

        if(this.executorService == null) throw new IllegalStateException("Not connected");
        return this.executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    publish(exchange, payload, finalProperties, finalPublish);
                } catch(Throwable e) {
                    logger.log(Level.SEVERE, "Unhandled exception publishing message type " + finalProperties.getType(), e);
                }
            }
        });
    }

    private ConnectionFactory createConnectionFactory() throws IOException {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUsername(this.config.getUsername());
        factory.setPassword(this.config.getPassword());
        factory.setVirtualHost(this.config.getVirtualHost());

        factory.setAutomaticRecoveryEnabled(true);
        factory.setConnectionTimeout(this.config.getConnectionTimeout());
        factory.setNetworkRecoveryInterval(this.config.getNetworkRecoveryInterval());

        if (this.threadFactory != null) {
            factory.setThreadFactory(this.threadFactory);
        }

        return factory;
    }

    @Override
    public void connect() throws IOException {
        if(config.getAddresses().isEmpty()) {
            logger.warning("Skipping AMQP connection because no addresses are configured");
        } else {
            logger.info("Connecting to AMQP API at " + Joiners.onCommaSpace.join(config.getAddresses()));
            this.connection = this.createConnectionFactory().newConnection(this.config.getAddresses().toArray(new Address[0]));
            this.channel = this.connection.createChannel();
        }
    }

    @Override
    public void disconnect() throws IOException {
        ExecutorUtils.shutdownImpatiently(executorService, logger, SHUTDOWN_TIMEOUT);

        if(channel != null) {
            channel.close();
            connection.close();
        }
    }
}