/**
 * Copyright (C) 2016 Etaia AS ([email protected])
 *
 * Licensed 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 com.hubrick.vertx.kafka.consumer;

import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.hubrick.vertx.kafka.consumer.config.KafkaConsumerConfiguration;
import com.hubrick.vertx.kafka.consumer.property.KafkaConsumerProperties;
import com.hubrick.vertx.kafka.consumer.util.PrometheusMetrics;
import com.hubrick.vertx.kafka.consumer.util.ThreadFactoryUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.eventbus.DeliveryOptions;
import io.vertx.core.json.JsonObject;
import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.errors.RetriableException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Vert.x Module to read from a Kafka Topic.
 *
 * @author Marcus Thiesen
 * @since 1.0.0
 */
public class KafkaConsumerVerticle extends AbstractVerticle {

    static final Double NON_STRICT_ORDERING_MESSAGES_PER_SECOND_DEFAULT = 20D;
    private static final Logger LOG = LoggerFactory.getLogger(KafkaConsumerVerticle.class);
    private static final AtomicLong INSTANCE_COUNTER = new AtomicLong();
    private static final Splitter COMMA_LIST_SPLITTER = Splitter.on(',').trimResults().omitEmptyStrings();
    private static final ThreadFactory CONSUMER_WATCHER_THREAD = ThreadFactoryUtil.createThreadFactory("kafka-consumer-watcher-thread-%d", LOG);
    private ExecutorService watcherExecutor = Executors.newSingleThreadExecutor(CONSUMER_WATCHER_THREAD);

    private volatile KafkaConsumerManager consumer;


    @Override
    public void start(final Future<Void> startedFuture) throws Exception {
        final JsonObject config = vertx.getOrCreateContext().config();
        final long instanceId = INSTANCE_COUNTER.getAndIncrement();
        final String vertxAddress = getMandatoryStringConfig(config, KafkaConsumerProperties.KEY_VERTX_ADDRESS);

        final KafkaConsumerConfiguration configuration = createKafkaConsumerConfiguration(config, instanceId);

        final PrometheusMetrics prometheusMetrics = PrometheusMetrics.create(configuration.getKafkaTopic(), configuration.getGroupId(), instanceId);

        watcherExecutor.execute(() -> watchStartConsumerManager(configuration, vertxAddress, startedFuture, prometheusMetrics));
    }

    KafkaConsumerConfiguration createKafkaConsumerConfiguration(final JsonObject config, final long instanceId) {
        final String clientIdPrefix = getMandatoryStringConfig(config, KafkaConsumerProperties.KEY_CLIENT_ID);

        final String topic = getMandatoryStringConfig(config, KafkaConsumerProperties.KEY_KAFKA_TOPIC);
        final String consumerGroup = getMandatoryStringConfig(config, KafkaConsumerProperties.KEY_GROUP_ID);

        final Boolean strictOrderingEnabled = config.getBoolean(KafkaConsumerProperties.KEY_STRICT_ORDERING, true);

        final Double messagesPerSecond = config.getDouble(KafkaConsumerProperties.KEY_MESSAGES_PER_SECOND, -1D);
        final Integer maxPollRecords = config.getInteger(KafkaConsumerProperties.KEY_MAX_POLL_RECORDS, 500);
        final Long maxPollIntervalMs = config.getLong(KafkaConsumerProperties.KEY_MAX_POLL_INTERVAL_MS, 300000L);
        final double maxPollIntervalSeconds = maxPollIntervalMs / 1000D;

        final Double effectiveMessagesPerSecond;
        if ((!strictOrderingEnabled) && messagesPerSecond < 0D) {
            effectiveMessagesPerSecond = NON_STRICT_ORDERING_MESSAGES_PER_SECOND_DEFAULT;
            LOG.warn("Strict ordering is disabled but no message limit given, limiting to {}", effectiveMessagesPerSecond);
        } else {
            effectiveMessagesPerSecond = messagesPerSecond;
        }

        final long effectiveMaxPollIntervalMs;
        if (effectiveMessagesPerSecond > 0D && effectiveMessagesPerSecond < (maxPollRecords / maxPollIntervalSeconds)) {
            effectiveMaxPollIntervalMs = (long) Math.ceil(maxPollRecords / effectiveMessagesPerSecond * 1000) * 2;
            LOG.warn("The configuration limits to handling {} messages per seconds, but number of polled records is {} that should be handled in {}ms." +
                            " This will cause the consumer to be marked as dead if there are more messages on the topic than are handled per second." +
                            " Setting the maxPollInterval to {}ms",
                    effectiveMessagesPerSecond, maxPollRecords, maxPollIntervalMs, effectiveMaxPollIntervalMs);
        } else {
            effectiveMaxPollIntervalMs = maxPollIntervalMs;
        }

        return KafkaConsumerConfiguration.create(
                consumerGroup,
                clientIdPrefix + "-" + instanceId,
                topic,
                getMandatoryStringConfig(config, KafkaConsumerProperties.KEY_BOOTSTRAP_SERVERS),
                config.getString(KafkaConsumerProperties.KEY_OFFSET_RESET, "latest"),
                config.getInteger(KafkaConsumerProperties.KEY_MAX_UNACKNOWLEDGED, 100),
                config.getLong(KafkaConsumerProperties.KEY_MAX_UNCOMMITTED_OFFSETS, 1000L),
                config.getLong(KafkaConsumerProperties.KEY_ACK_TIMEOUT_SECONDS, 240L),
                config.getLong(KafkaConsumerProperties.KEY_COMMIT_TIMEOUT_MS, 5 * 60 * 1000L),
                config.getInteger(KafkaConsumerProperties.KEY_MAX_RETRIES, Integer.MAX_VALUE),
                config.getInteger(KafkaConsumerProperties.KEY_INITIAL_RETRY_DELAY_SECONDS, 1),
                config.getInteger(KafkaConsumerProperties.KEY_MAX_RETRY_DELAY_SECONDS, 10),
                config.getLong(KafkaConsumerProperties.KEY_EVENT_BUS_SEND_TIMEOUT, DeliveryOptions.DEFAULT_TIMEOUT),
                effectiveMessagesPerSecond,
                config.getBoolean(KafkaConsumerProperties.KEY_COMMIT_ON_PARTITION_CHANGE, true),
                strictOrderingEnabled,
                maxPollRecords,
                effectiveMaxPollIntervalMs,
                COMMA_LIST_SPLITTER.splitToList(config.getString(KafkaConsumerProperties.KEY_METRIC_CONSUMER_CLASSES, "")),
                config.getString(KafkaConsumerProperties.KEY_METRIC_DROPWIZARD_REGISTRY_NAME, "")
        );
    }


    private void watchStartConsumerManager(final KafkaConsumerConfiguration configuration,
                                           final String vertxAddress,
                                           final Future<Void> startedFuture,
                                           final PrometheusMetrics prometheusMetrics) {
        try {
            final java.util.concurrent.Future<?> future = startConsumerManager(configuration, vertxAddress, startedFuture, prometheusMetrics);
            future.get();
            LOG.info("{}: Consumer manager run loop has returned, restarting", configuration.getKafkaTopic());
            stopConsumerManager();
            watcherExecutor.execute(() -> watchStartConsumerManager(configuration, vertxAddress, startedFuture, prometheusMetrics));
        } catch (InterruptedException e) {
            LOG.info("{}: ConsumerManager got interrupted, returning", configuration.getKafkaTopic());
            stopConsumerManager();
            watcherExecutor.shutdownNow();
        } catch (ExecutionException e) {
            LOG.warn("{}: ExecutionException in consumer manager, restarting", configuration.getKafkaTopic(), e);
            stopConsumerManager();
            watcherExecutor.execute(() -> watchStartConsumerManager(configuration, vertxAddress, startedFuture, prometheusMetrics));
        } catch (RetriableException e) {
            LOG.warn("{}: RetriableException in consumer manager, restarting", configuration.getKafkaTopic(), e);
            stopConsumerManager();
            watcherExecutor.execute(() -> watchStartConsumerManager(configuration, vertxAddress, startedFuture, prometheusMetrics));
        } catch (KafkaException e) {
            LOG.error("{}: KafkaException in consumer manager, returning", configuration.getKafkaTopic(), e);
            stopConsumerManager();
            watcherExecutor.shutdownNow();
            startedFuture.tryFail(e);
        }
    }

    private java.util.concurrent.Future<?> startConsumerManager(final KafkaConsumerConfiguration configuration,
                                                                final String vertxAddress,
                                                                final Future<Void> startedFuture,
                                                                final PrometheusMetrics prometheusMetrics) {
        consumer = KafkaConsumerManager.create(vertx, configuration, prometheusMetrics, makeHandler(configuration, vertxAddress));
        return consumer.start(startedFuture);
    }

    private String getMandatoryStringConfig(final JsonObject jsonObject, final String key) {
        final String value = jsonObject.getString(key);
        if (Strings.isNullOrEmpty(value)) {
            throw new IllegalArgumentException("No configuration for key " + key + " found");
        }
        return value;
    }

    private KafkaConsumerHandler makeHandler(final KafkaConsumerConfiguration configuration, final String vertxAddress) {
        return (message, futureResult) -> {
            final DeliveryOptions options = new DeliveryOptions();
            options.setSendTimeout(configuration.getEventBusSendTimeout());

            vertx.eventBus().send(vertxAddress, message, options, (result) -> {
                if (result.succeeded()) {
                    futureResult.complete();
                } else {
                    futureResult.fail(result.cause());
                }
            });
        };
    }

    @Override
    public void stop() throws Exception {
        stopConsumerManager();
        super.stop();
    }

    private void stopConsumerManager() {
        if (consumer != null) {
            consumer.stop();
        }
    }
}