package ru.yandex.qe.dispenser.quartz.monitoring;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

import com.google.common.base.Ticker;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.JobKey;
import org.quartz.JobListener;
import org.quartz.Scheduler;
import org.quartz.Trigger;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;

import ru.yandex.qe.dispenser.solomon.SolomonHolder;
import ru.yandex.qe.dispenser.quartz.trigger.SingleTrigger;
import ru.yandex.monlib.metrics.histogram.Histograms;
import ru.yandex.monlib.metrics.labels.Labels;
import ru.yandex.monlib.metrics.primitives.Histogram;
import ru.yandex.monlib.metrics.primitives.LazyGaugeDouble;
import ru.yandex.monlib.metrics.primitives.LazyGaugeInt64;
import ru.yandex.monlib.metrics.primitives.Rate;
import ru.yandex.monlib.metrics.registry.MetricRegistry;

public class MonitoringJobListener implements JobListener, ApplicationContextAware, ApplicationListener<ContextRefreshedEvent> {

    private static final Logger LOG = LoggerFactory.getLogger(MonitoringJobListener.class);

    private static final int WINDOW_SIZE = 10;
    private static final String TIME_SINCE_LAST_FIRE = "scheduler.time_since_last_fire";
    private static final String TIME_SINCE_LAST_SUCCESS = "scheduler.time_since_last_success";
    private static final String FIRE_RATE = "scheduler.fire_rate";
    private static final String VETO_RATE = "scheduler.veto_rate";
    private static final String FAILURE_RATE = "scheduler.failure_rate";
    private static final String SUCCESS_RATE = "scheduler.success_rate";
    private static final String JOB_DURATION = "scheduler.job_duration";
    private static final String FAILURE_RATIO = "scheduler.failure_ratio";
    private static final String JOB_DURATION_AVG = "scheduler.job_duration_avg";
    private static final String SCHEDULER = "scheduler";
    private static final String JOB = "job";
    private static final String TRIGGER = "trigger";

    private final String schedulerName;
    private final String listenerName;
    private final MetricRegistry rootRegistry;
    private final ConcurrentHashMap<TriggerKey, Rate> fireRates = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Rate> vetoRates = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Rate> successRates = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Rate> failureRates = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Histogram> jobDurations = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Long> lastFires = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, Long> lastSuccesses = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, LazyGaugeInt64> lastFireGauges = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, LazyGaugeInt64> lastSuccessGauges = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, JobFailuresAggregator> failureAggregators = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, LazyGaugeDouble> failureRatioGauges = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, MovingAverage> jobDurationMovingAverages = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<TriggerKey, LazyGaugeInt64> jobDurationAvgGauges = new ConcurrentHashMap<>();
    private final long initializedAt = Ticker.systemTicker().read();

    private ApplicationContext applicationContext;
    private boolean aggregateByGroup = false;


    public MonitoringJobListener(final String schedulerName, final SolomonHolder solomonHolder) {
        this.schedulerName = schedulerName;
        this.listenerName = "MonitoringJobListener_" + schedulerName;
        this.rootRegistry = solomonHolder.getRootRegistry();
    }

    public MonitoringJobListener(final String schedulerName, final SolomonHolder solomonHolder, final boolean aggregateByGroup) {
        this(schedulerName, solomonHolder);
        this.aggregateByGroup = aggregateByGroup;
    }

    @Override
    public String getName() {
        return listenerName;
    }

    private JobKey getJobKey(final JobExecutionContext context) {
        return aggregateByGroup
                ? new JobKey("", context.getJobDetail().getKey().getGroup())
                : context.getJobDetail().getKey();
    }

    private TriggerKey getTriggerKey(final JobExecutionContext context) {
        return aggregateByGroup
                ? new TriggerKey("", context.getTrigger().getKey().getGroup())
                : context.getTrigger().getKey();
    }

    @Override
    public void jobToBeExecuted(final JobExecutionContext context) {
        final TriggerKey triggerKey = getTriggerKey(context);
        final JobKey jobKey = getJobKey(context);
        final String triggerName = triggerKey.getGroup() + "." + triggerKey.getName();
        final String jobName = jobKey.getGroup() + "." + jobKey.getName();
        final Rate rate = fireRates.computeIfAbsent(triggerKey, k -> rootRegistry.rate(FIRE_RATE,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName)));
        rate.inc();
        lastFires.put(triggerKey, Ticker.systemTicker().read());
        lastFireGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_FIRE,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                        - lastFires.getOrDefault(triggerKey, initializedAt))));
    }

    @Override
    public void jobExecutionVetoed(final JobExecutionContext context) {
        final TriggerKey triggerKey = getTriggerKey(context);
        final JobKey jobKey = getJobKey(context);
        final String triggerName = triggerKey.getGroup() + "." + triggerKey.getName();
        final String jobName = jobKey.getGroup() + "." + jobKey.getName();
        final Rate rate = vetoRates.computeIfAbsent(triggerKey, k -> rootRegistry.rate(VETO_RATE,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName)));
        rate.inc();
    }

    @Override
    public void jobWasExecuted(final JobExecutionContext context, final JobExecutionException jobException) {
        final TriggerKey triggerKey = getTriggerKey(context);
        final JobKey jobKey = getJobKey(context);
        final String triggerName = triggerKey.getGroup() + "." + triggerKey.getName();
        final String jobName = jobKey.getGroup() + "." + jobKey.getName();
        final boolean success = jobException == null;
        if (success) {
            final Rate rate = successRates.computeIfAbsent(triggerKey, k -> rootRegistry.rate(SUCCESS_RATE,
                    Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName)));
            rate.inc();
            lastSuccesses.put(triggerKey, Ticker.systemTicker().read());
            lastSuccessGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_SUCCESS,
                    Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                    () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                            - lastSuccesses.getOrDefault(triggerKey, initializedAt))));
        } else {
            final Rate rate = failureRates.computeIfAbsent(triggerKey, k -> rootRegistry.rate(FAILURE_RATE,
                    Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName)));
            rate.inc();
        }
        final Histogram histogram = jobDurations.computeIfAbsent(triggerKey, k -> rootRegistry.histogramRate(JOB_DURATION,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                Histograms.exponential(22, 2.0d, 1.0d)));
        histogram.record(context.getJobRunTime());
        failureAggregators.computeIfAbsent(triggerKey, k -> new JobFailuresAggregator(WINDOW_SIZE)).aggregate(!success);
        failureRatioGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeDouble(FAILURE_RATIO,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName), () -> {
                    final JobFailuresAggregator aggregator = failureAggregators.get(triggerKey);
                    if (aggregator != null) {
                        return aggregator.failureRatio();
                    } else {
                        return 0.0d;
                    }
                }));
        jobDurationMovingAverages.computeIfAbsent(triggerKey, k -> new MovingAverage(WINDOW_SIZE)).update(context.getJobRunTime());
        jobDurationAvgGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeInt64(JOB_DURATION_AVG,
                Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName), () -> {
                    final MovingAverage movingAverage = jobDurationMovingAverages.get(triggerKey);
                    if (movingAverage != null) {
                        return movingAverage.getAverage();
                    } else {
                        return 0L;
                    }
                }));
    }

    @Override
    public void onApplicationEvent(final ContextRefreshedEvent event) {
        if (!event.getApplicationContext().getId().equals(applicationContext.getId())) {
            return;
        }
        if (aggregateByGroup) {
            aggregationCase();
            return;
        }
        try {
            final Scheduler scheduler = (Scheduler) applicationContext.getBean(schedulerName);
            final Set<JobKey> jobKeys = scheduler.getJobKeys(GroupMatcher.anyJobGroup());
            for (final JobKey jobKey : jobKeys) {
                final List<? extends Trigger> triggers = scheduler.getTriggersOfJob(jobKey);
                for (final Trigger trigger : triggers) {
                    final String triggerName = trigger.getKey().getGroup() + "." + trigger.getKey().getName();
                    final String jobName = jobKey.getGroup() + "." + jobKey.getName();
                    lastSuccessGauges.computeIfAbsent(trigger.getKey(), k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_SUCCESS,
                            Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                            () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                                    - lastSuccesses.getOrDefault(trigger.getKey(), initializedAt))));
                    lastFireGauges.computeIfAbsent(trigger.getKey(), k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_FIRE,
                            Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                            () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                                    - lastFires.getOrDefault(trigger.getKey(), initializedAt))));
                    failureRatioGauges.computeIfAbsent(trigger.getKey(), k -> rootRegistry.lazyGaugeDouble(FAILURE_RATIO,
                            Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName), () -> {
                                final JobFailuresAggregator aggregator = failureAggregators.get(trigger.getKey());
                                if (aggregator != null) {
                                    return aggregator.failureRatio();
                                } else {
                                    return 0.0d;
                                }
                            }));
                }
            }
        } catch (final Exception e) {
            LOG.error("Failed to pre-register job metrics", e);
        }
    }

    private void aggregationCase() {
        try {
            final Set<SingleTrigger> singleTriggers = new HashSet<>(applicationContext.getBeansOfType(SingleTrigger.class)
                    .values());

            for (final SingleTrigger trigger : singleTriggers) {
                final TriggerKey triggerKey = new TriggerKey("", trigger.getTriggerGroup());
                final String triggerName = triggerKey.getGroup() + "." + triggerKey.getName();

                final String jobName = trigger.getJobGroup();
                lastSuccessGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_SUCCESS,
                        Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                        () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                                - lastSuccesses.getOrDefault(triggerKey, initializedAt))));
                lastFireGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeInt64(TIME_SINCE_LAST_FIRE,
                        Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName),
                        () -> TimeUnit.NANOSECONDS.toMillis(Ticker.systemTicker().read()
                                - lastFires.getOrDefault(triggerKey, initializedAt))));
                failureRatioGauges.computeIfAbsent(triggerKey, k -> rootRegistry.lazyGaugeDouble(FAILURE_RATIO,
                        Labels.of(SCHEDULER, schedulerName, JOB, jobName, TRIGGER, triggerName), () -> {
                            final JobFailuresAggregator aggregator = failureAggregators.get(triggerKey);
                            if (aggregator != null) {
                                return aggregator.failureRatio();
                            } else {
                                return 0.0d;
                            }
                        }));
            }
        } catch (final Exception e) {
            LOG.error("Failed to pre-register job metrics", e);
        }
    }

    @Override
    public void setApplicationContext(final ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

}
