package ru.yandex.webmaster3.core.solomon.metric;

import org.apache.commons.lang3.tuple.Pair;
import org.joda.time.Duration;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.DoubleSupplier;
import java.util.function.Function;

/**
 * @author avhaliullin
 */
public class SolomonMetricRegistry {
    private final ConcurrentHashMap<SolomonKey, SolomonCounter> indicators = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<SolomonKey, SolomonGauge<? extends Number>> gauges = new ConcurrentHashMap<>();

    public SolomonCounter createCounter(SolomonMetricConfiguration configuration, SolomonKey key) {
        if (!configuration.isEnable()) {
            return DUMMY_COUNTER;
        }

        String indicator = configuration.getIndicator();
        Function<SolomonKey, SolomonCounter> counterProvider = ign -> new BasicCounter(configuration.getExportCoefficient());
        if (configuration.getGroupBy() != null && !configuration.getGroupBy().isEmpty()) {
            return SolomonGroupingUtil.registerCounterWithGroupings(indicators, indicator, configuration.getGroupBy(), key, counterProvider);
        } else {
            return indicators.computeIfAbsent(key, counterProvider);
        }
    }

    public <T extends Comparable<? super T>> SolomonHistogram<T> createHistogram(SolomonMetricConfiguration configuration, Map<T, SolomonKey> bucketsMap) {
        return SolomonHistogramImpl.create((t) -> createCounter(configuration, bucketsMap.get(t)), bucketsMap.keySet());
    }

    public SolomonCounter createSimpleCounter(SolomonKey key, double exportCoefficient) {
        return indicators.computeIfAbsent(key, ign -> new BasicCounter(exportCoefficient));
    }

    public SolomonGauge<Long> createGauge(SolomonKey key) {
        return (SolomonGauge<Long>)gauges.computeIfAbsent(key, k -> new BasicGauge());
    }

    public SolomonGauge<Double> createMicrometerGauge(SolomonKey key, DoubleSupplier valueSupplier) {
        return (SolomonGauge<Double>)gauges.computeIfAbsent(key, k -> new MicrometerGauge(valueSupplier));
    }

    public SolomonTimer createTimer(SolomonTimerConfiguration configuration, SolomonKey key) {
        if (!configuration.isEnable()) {
            return DUMMY_TIMER;
        }

        if (configuration.getBuckets().isEmpty()) {
            throw new RuntimeException("Invalid timer configuration: no buckets found");
        }

        TimeUnit timeUnit = configuration.getTimeUnit();
        Function<Duration, SolomonCounter> bucketProvider = duration -> {
            String fullIndicatorName = durationWithUnit(duration, timeUnit);
            return createCounter(configuration, key.withLabel(SolomonKey.LABEL_TIME_BUCKET, fullIndicatorName));
        };

        SolomonCounter durationCounter;
        if (configuration.getDurationIndicatorName() != null && configuration.getDurationGroupBy() != null) {
            SolomonMetricConfiguration metricConfiguration = configuration.copy();
            metricConfiguration.setParsedGroupBy(configuration.getDurationGroupBy());
            metricConfiguration.setIndicator(configuration.getDurationIndicatorName());
            metricConfiguration.setExportCoefficient(1d / (double) TimeUnit.SECONDS.toMillis(1L));// отгружаем в соломон секунды
            durationCounter = createCounter(metricConfiguration, key);
        } else {
            durationCounter = DUMMY_COUNTER;
        }

        return new SolomonTimerImpl(SolomonHistogramImpl.create(bucketProvider, configuration.getBuckets()), durationCounter);
    }

    private IndicatorsSnapshot getIndicatorsCommitableSnapshot() {
        List<Pair<SolomonCounter, Long>> commitPairs = new ArrayList<>();
        Map<SolomonKey, Number> snapshots = new HashMap<>(indicators.size() + gauges.size());
        for (Map.Entry<SolomonKey, SolomonCounter> entry : indicators.entrySet()) {
            SolomonCounter solomonCounter = entry.getValue();
            long value = solomonCounter.get();
            commitPairs.add(Pair.of(solomonCounter, value));
            snapshots.put(entry.getKey(), (long) (value * solomonCounter.getExportCoefficient()));
        }

        gauges.forEach((key, value) -> snapshots.put(key, value.get()));

        return new IndicatorsSnapshot(
                Collections.unmodifiableList(commitPairs),
                Collections.unmodifiableMap(snapshots)
        );
    }

    public Map<SolomonKey, Number> getIndicatorsSnapshot() {
        IndicatorsSnapshot snapshot = getIndicatorsCommitableSnapshot();
        snapshot.commit();
        return snapshot.getIndicators();
    }

    private static class IndicatorsSnapshot {
        private final List<Pair<SolomonCounter, Long>> commitPairs;
        private final Map<SolomonKey, Number> snapshot;
        private AtomicBoolean isCommitted = new AtomicBoolean(false);

        IndicatorsSnapshot(List<Pair<SolomonCounter, Long>> commitPairs, Map<SolomonKey, Number> snapshot) {
            this.commitPairs = commitPairs;
            this.snapshot = snapshot;
        }

        public Map<SolomonKey, Number> getIndicators() {
            return snapshot;
        }

        public void commit() {
            if (isCommitted.compareAndSet(false, true)) {
                for (Pair<SolomonCounter, Long> pair : commitPairs) {
                    pair.getLeft().add(-pair.getRight());
                }
            }
        }
    }

    private static class SolomonTimerImpl implements SolomonTimer {
        private final SolomonHistogram<Duration> histogram;
        private final SolomonCounter durationCounter;

        SolomonTimerImpl(SolomonHistogram<Duration> histogram, SolomonCounter durationCounter) {
            this.histogram = histogram;
            this.durationCounter = durationCounter;
        }

        @Override
        public void add(Duration key, long amount) {
            histogram.add(key, amount);
            durationCounter.add(key.getMillis());
        }
    }

    private static class BasicGauge implements SolomonGauge<Long> {
        private volatile long value;

        @Override
        public void set(Long value) {
            this.value = value;
        }

        @Override
        public Long get() {
            return value;
        }
    }

    private static class MicrometerGauge implements SolomonGauge<Double> {
        private final DoubleSupplier valueSupplier;

        MicrometerGauge(DoubleSupplier valueSupplier) {
            this.valueSupplier = valueSupplier;
        }

        @Override
        public void set(Double value) {
            throw new UnsupportedOperationException();
        }

        @Override
        public Double get() {
            return valueSupplier.getAsDouble();
        }
    }

    private static class BasicCounter implements SolomonCounter {
        private final AtomicLong counter;
        private final double exportCoefficient;

        private BasicCounter(double exportCoefficient) {
            this.counter = new AtomicLong(0L);
            this.exportCoefficient = exportCoefficient;
        }

        @Override
        public void add(long value) {
            counter.addAndGet(value);
        }

        @Override
        public long get() {
            return counter.get();
        }

        public double getExportCoefficient() {
            return exportCoefficient;
        }
    }

    private static final SolomonCounter DUMMY_COUNTER = new SolomonCounter() {
        @Override
        public void add(long value) {
        }

        @Override
        public long get() {
            return 0;
        }
    };

    private static final SolomonTimer DUMMY_TIMER = (ign1, ign2) -> {
    };

    private static String durationWithUnit(Duration duration, TimeUnit timeUnit) {
        long durationValue;
        switch (timeUnit) {
            case DAYS:
                durationValue = duration.getStandardDays();
                break;
            case HOURS:
                durationValue = duration.getStandardHours();
                break;
            case MINUTES:
                durationValue = duration.getStandardMinutes();
                break;
            case SECONDS:
                durationValue = duration.getStandardSeconds();
                break;
            case MILLISECONDS:
                durationValue = duration.getMillis();
                break;
            case NANOSECONDS:
            case MICROSECONDS:
            default:
                throw new RuntimeException("Time unit not supported: " + timeUnit);
        }
        return "<" + durationValue + timeUnitLabel(timeUnit);
    }

    private static String timeUnitLabel(TimeUnit timeUnit) {
        switch (timeUnit) {
            case DAYS:
                return "d";
            case HOURS:
                return "h";
            case MINUTES:
                return "m";
            case SECONDS:
                return "s";
            case MILLISECONDS:
                return "ms";
            case MICROSECONDS:
                return "μs";
            case NANOSECONDS:
                return "ns";
            default:
                return timeUnit.name().toLowerCase();
        }
    }
}
