/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.jackrabbit.oak.plugins.document.persistentCache;

import org.apache.jackrabbit.api.stats.TimeSeries;
import org.apache.jackrabbit.oak.api.jmx.PersistentCacheStatsMBean;
import org.apache.jackrabbit.oak.commons.IOUtils;
import org.apache.jackrabbit.oak.commons.jmx.AnnotatedStandardMBean;
import org.apache.jackrabbit.oak.stats.CounterStats;
import org.apache.jackrabbit.oak.stats.Counting;
import org.apache.jackrabbit.oak.stats.MeterStats;
import org.apache.jackrabbit.oak.stats.StatisticsProvider;
import org.apache.jackrabbit.oak.stats.StatsOptions;
import org.apache.jackrabbit.oak.stats.TimerStats;
import org.apache.jackrabbit.stats.TimeSeriesStatsUtil;

import javax.management.openmbean.CompositeData;
import java.util.Map;
import java.util.StringJoiner;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Persistence Cache Statistics.
 */
public class PersistentCacheStats extends AnnotatedStandardMBean implements PersistentCacheStatsMBean {

    private static final Boolean ENABLE_READ_TIMER;
    private static final Boolean ENABLE_LOAD_TIMER;
    private static final Boolean ENABLE_REJECTED_PUT;
    static {
        String enableReadTimer = System.getProperty("PersistentCacheStats.readTimer", "false");
        String enableLoadTimer = System.getProperty("PersistentCacheStats.loadTimer", "false");
        String enableRejectedPut = System.getProperty("PersistentCacheStats.rejectedPut", "false");
        ENABLE_READ_TIMER = Boolean.parseBoolean(enableReadTimer);
        ENABLE_LOAD_TIMER = Boolean.parseBoolean(enableLoadTimer);
        ENABLE_REJECTED_PUT = Boolean.parseBoolean(enableRejectedPut);
    }

    private static final String HITS = "HITS";
    private static final String REQUESTS = "REQUESTS";
    private static final String LOAD_TIMER = "LOAD_TIMER";
    private static final String LOAD_EXCEPTIONS = "LOAD_EXCEPTIONS";
    private static final String PUT_ONE = "CACHE_PUT";
    private static final String BROADCAST_RECV = "BROADCAST_RECV";
    private static final String INVALIDATE_ONE = "INVALIDATE_ONE";
    private static final String INVALIDATE_ALL = "INVALIDATE_ALL";
    private static final String READ_TIMER = "READ_TIMER";
    private static final String USED_DISK_SPACE = "USED_SPACE_BYTES";
    private static final String PUT_REJECTED_ALREADY_PERSISTED = "PUT_REJECTED_ALREADY_PERSISTED";
    private static final String PUT_REJECTED_ENTRY_NOT_USED = "PUT_REJECTED_ENTRY_NOT_USED";
    private static final String PUT_REJECTED_FULL_QUEUE = "PUT_REJECTED_FULL_QUEUE";
    private static final String PUT_REJECTED_SECONDARY_CACHE = "PUT_REJECTED_SECONDARY_CACHE";

    private final StatisticsProvider statisticsProvider;
    private final String cacheName;

    private final MeterStats hitMeter;
    private final TimeSeries hitRateHistory;

    private final MeterStats requestMeter;
    private final TimeSeries requestRateHistory;

    private final MeterStats loadExceptionMeter;
    private final TimeSeries loadExceptionRateHistory;

    private final TimerStats loadTimer;
    private final TimeSeries loadRateHistory;

    private final MeterStats putMeter;
    private final TimeSeries putRateHistory;

    private final MeterStats broadcastRecvMeter;
    private final TimeSeries broadcastRecvRateHistory;

    private final MeterStats invalidateOneMeter;
    private final TimeSeries invalidateOneRateHistory;

    private final MeterStats invalidateAllMeter;
    private final TimeSeries invalidateAllRateHistory;

    private final MeterStats putRejectedAlreadyPersistedMeter;
    private final TimeSeries putRejectedAlreadyPersistedHistory;

    private final MeterStats putRejectedEntryNotUsedMeter;
    private final TimeSeries putRejectedEntryNotUseHistory;

    private final MeterStats putRejectedByFullQueueMeter;
    private final TimeSeries putRejectedByFullQueueHistory;

    private final MeterStats putRejectedAsCachedInSecMeter;
    private final TimeSeries putRejectedAsCachedInSecHistory;

    private final TimerStats readTimer;

    private final CounterStats usedSpaceByteCounter;
    private final TimeSeries usedSpaceByteCounterHistory;

    private final UsedSpaceTracker diskStats;

    private final TimeSeries hitPercentageHistory;


    public PersistentCacheStats(CacheType cacheType, StatisticsProvider provider) {
        super(PersistentCacheStatsMBean.class);

        if (provider == null) {
            statisticsProvider = StatisticsProvider.NOOP;
        } else {
            statisticsProvider = provider;
        }

        // Configure cache name
        cacheName = "PersistentCache.NodeCache." + cacheType.name().toLowerCase();

        // Fetch stats and time series
        String statName;

        statName = getStatName(HITS, cacheName);
        hitMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        hitRateHistory = getTimeSeries(statName);

        statName = getStatName(REQUESTS, cacheName);
        requestMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        requestRateHistory = getTimeSeries(statName);
        hitPercentageHistory = new PercentageTimeSeries(hitRateHistory, requestRateHistory);

        statName = getStatName(LOAD_TIMER, cacheName);
        loadRateHistory = new DifferenceTimeSeries(requestRateHistory, hitRateHistory);
        if (ENABLE_LOAD_TIMER) {
            loadTimer = statisticsProvider.getTimer(statName, StatsOptions.METRICS_ONLY);
        }
        else {
            loadTimer = StatisticsProvider.NOOP.getTimer(statName, StatsOptions.METRICS_ONLY);
        }

        statName = getStatName(LOAD_EXCEPTIONS, cacheName);
        loadExceptionMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        loadExceptionRateHistory = getTimeSeries(statName);

        statName = getStatName(PUT_ONE, cacheName);
        putMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        putRateHistory = getTimeSeries(statName);

        statName = getStatName(BROADCAST_RECV, cacheName);
        broadcastRecvMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        broadcastRecvRateHistory = getTimeSeries(statName);

        statName = getStatName(INVALIDATE_ONE, cacheName);
        invalidateOneMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        invalidateOneRateHistory = getTimeSeries(statName);

        statName = getStatName(INVALIDATE_ALL, cacheName);
        invalidateAllMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        invalidateAllRateHistory = getTimeSeries(statName);

        statName = getStatName(USED_DISK_SPACE, cacheName);
        usedSpaceByteCounter = statisticsProvider.getCounterStats(statName, StatsOptions.DEFAULT);
        usedSpaceByteCounterHistory = getTimeSeries(statName, false);

        statName = getStatName(READ_TIMER, cacheName);
        if (ENABLE_READ_TIMER) {
            readTimer = statisticsProvider.getTimer(statName, StatsOptions.METRICS_ONLY);
        }
        else {
            readTimer = StatisticsProvider.NOOP.getTimer(statName, StatsOptions.METRICS_ONLY);
        }

        statName = getStatName(PUT_REJECTED_ALREADY_PERSISTED, cacheName);
        if (ENABLE_REJECTED_PUT) {
            putRejectedAlreadyPersistedMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedAlreadyPersistedHistory = getTimeSeries(statName);
        } else {
            putRejectedAlreadyPersistedMeter = StatisticsProvider.NOOP.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedAlreadyPersistedHistory = StatisticsProvider.NOOP.getStats().getTimeSeries(statName, false);
        }

        statName = getStatName(PUT_REJECTED_ENTRY_NOT_USED, cacheName);
        if (ENABLE_REJECTED_PUT) {
            putRejectedEntryNotUsedMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedEntryNotUseHistory = getTimeSeries(statName);
        } else {
            putRejectedEntryNotUsedMeter = StatisticsProvider.NOOP.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedEntryNotUseHistory = StatisticsProvider.NOOP.getStats().getTimeSeries(statName, false);
        }

        statName = getStatName(PUT_REJECTED_FULL_QUEUE, cacheName);
        if (ENABLE_REJECTED_PUT) {
            putRejectedByFullQueueMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedByFullQueueHistory = getTimeSeries(statName);
        } else {
            putRejectedByFullQueueMeter = StatisticsProvider.NOOP.getMeter(statName, StatsOptions.DEFAULT);
            putRejectedByFullQueueHistory = StatisticsProvider.NOOP.getStats().getTimeSeries(statName, false);
        }

        statName = getStatName(PUT_REJECTED_SECONDARY_CACHE, cacheName);
        putRejectedAsCachedInSecMeter = statisticsProvider.getMeter(statName, StatsOptions.DEFAULT);
        putRejectedAsCachedInSecHistory = getTimeSeries(statName);

        diskStats = new UsedSpaceTracker(usedSpaceByteCounter);
    }

    //~--------------------------------------< stats update methods

    public void markHit() {
        hitMeter.mark();
    }

    public void markRequest() {
        requestMeter.mark();
    }

    public void markException() {
        loadExceptionMeter.mark();
    }

    public void markPut() {
        putMeter.mark();
    }

    public void markRecvBroadcast() {
        broadcastRecvMeter.mark();
    }

    public void markInvalidateOne() {
        invalidateOneMeter.mark();
    }

    public void markInvalidateAll() {
        invalidateAllMeter.mark();
    }

    public void markPutRejectedAlreadyPersisted() {
        putRejectedAlreadyPersistedMeter.mark();
    }

    public void markPutRejectedEntryNotUsed() {
        putRejectedEntryNotUsedMeter.mark();
    }

    public void markPutRejectedAsCachedInSecondary() {
        putRejectedAsCachedInSecMeter.mark();
    }

    public void markPutRejectedQueueFull() {
        putRejectedByFullQueueMeter.mark();
    }

    public TimerStats.Context startReadTimer() {
        return this.readTimer.time();
    }

    public TimerStats.Context startLoaderTimer() {
        return this.loadTimer.time();
    }

    // Update disk space

    public void addWriteGeneration(int generation) {
        diskStats.addWriteGeneration(generation);
    }

    public void removeReadGeneration(int generation) {
        diskStats.removeReadGeneration(generation);
    }

    public void markBytesWritten(long numBytes) {
        diskStats.markBytesWritten(numBytes);
    }

    //~--------------------------------------< diskspace usage helper

    static class UsedSpaceTracker {
        private final CounterStats byteCounter;

        private final Map<Integer, AtomicLong> generationByteCounters;

        private AtomicLong currentGenCounter;

        UsedSpaceTracker(CounterStats usageCounter) {
            this.byteCounter = usageCounter;
            this.generationByteCounters = new ConcurrentHashMap<Integer, AtomicLong>();
            this.currentGenCounter = new AtomicLong();
        }

        void addWriteGeneration(int generation) {
            currentGenCounter = new AtomicLong(0L);
            generationByteCounters.put(generation, currentGenCounter);
        }

        void removeReadGeneration(int generation) {
            AtomicLong genCounter = generationByteCounters.remove(generation);
            byteCounter.dec(genCounter == null ? 0L : genCounter.get());
        }

        void markBytesWritten(long bytes) {
            currentGenCounter.addAndGet(bytes);
            byteCounter.inc(bytes);
        }
    }

    //~--------------------------------------< CacheStatsMbean

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

    @Override
    public long getRequestCount() {
        return requestMeter.getCount();
    }

    @Override
    public long getHitCount() {
        return hitMeter.getCount();
    }

    @Override
    public double getHitRate() {
        long hitCount = hitMeter.getCount();
        long requestCount = requestMeter.getCount();
        return (requestCount == 0L ? 0L : (double)hitCount/requestCount);
    }

    @Override
    public long getMissCount() {
        return requestMeter.getCount() - hitMeter.getCount();
    }

    @Override
    public double getMissRate() {
        long missCount = getMissCount();
        long requestCount = requestMeter.getCount();
        return (requestCount == 0L ? 0L : (double)missCount/requestCount);
    }

    @Override
    public long getLoadCount() {
        return getMissCount();
    }

    @Override
    public long getLoadSuccessCount() {
        return getLoadCount() - getLoadExceptionCount();
    }

    @Override
    public long getLoadExceptionCount() {
        return loadExceptionMeter.getCount();
    }

    @Override
    public double getLoadExceptionRate() {
        long exceptionCount = loadExceptionMeter.getCount();
        long loadCount = loadTimer.getCount();
        return (loadCount == 0L ? 0L : (double)exceptionCount/loadCount);
    }

    @Override
    public long estimateCurrentWeight() {
        return usedSpaceByteCounter.getCount();
    }


    @Override
    public CompositeData getRequestRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(requestRateHistory, "Persistent cache requests");
    }

    @Override
    public CompositeData getHitRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(hitRateHistory, "Persistent cache hits");
    }

    @Override
    public CompositeData getLoadRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(loadRateHistory, "Persistent cache loads/misses");
    }

    @Override
    public CompositeData getLoadExceptionRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(loadExceptionRateHistory, "Persistent cache load exceptions");
    }

    @Override
    public CompositeData getHitPercentageHistory() {
        return TimeSeriesStatsUtil.asCompositeData(hitPercentageHistory, "Persistent cache hit percentage");
    }

    @Override
    public CompositeData getPutRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(putRateHistory, "Persistent cache manual put entry");
    }

    @Override
    public CompositeData getPutRejectedAlreadyPersistedRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(putRejectedAlreadyPersistedHistory, "Persistent cache put rejected (already persisted)");
    }

    @Override
    public CompositeData getPutRejectedEntryNotUsedRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(putRejectedEntryNotUseHistory, "Persistent cache put rejected (entry not used)");
    }

    @Override
    public CompositeData getPutRejectedQueueFullRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(putRejectedByFullQueueHistory, "Persistent cache put rejected (queue is full)");
    }

    @Override
    public CompositeData getPutRejectedAsCachedInSecRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(putRejectedAsCachedInSecHistory, "Persistent cache put rejected " +
                "(entry is covered by secondary)");
    }

    @Override
    public CompositeData getInvalidateOneRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(invalidateOneRateHistory, "Persistent cache invalidate one entry");
    }

    @Override
    public CompositeData getInvalidateAllRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(invalidateAllRateHistory, "Persistent cache invalidate all entries");
    }

    @Override
    public CompositeData getBroadcastRecvRateHistory() {
        return TimeSeriesStatsUtil.asCompositeData(broadcastRecvRateHistory, "Persistent cache entries received from broadcast");
    }

    @Override
    public CompositeData getUsedSpaceHistory() {
        return TimeSeriesStatsUtil.asCompositeData(usedSpaceByteCounterHistory, "Persistent cache estimated size (bytes)");
    }

    @Override
    public String cacheInfoAsString() {
        return new StringJoiner(", ", "PersistentCacheStats [", "]")
                .add("requestCount=" + getRequestCount())
                .add("hitCount=" + getHitCount())
                .add("hitRate=" + String.format("%1.2f", getHitRate()))
                .add("missCount=" + getMissCount())
                .add("missRate=" + String.format("%1.2f", getMissRate()))
                .add("loadCount=" + getLoadCount())
                .add("loadSuccessCount=" + getLoadSuccessCount())
                .add("loadExceptionCount=" + getLoadExceptionCount())
                .add("totalWeight=" + IOUtils.humanReadableByteCount(estimateCurrentWeight()))
                .toString();
    }

    //~--------------------------------------< CacheStatsMbean - stats that are not (yet) available
    @Override
    public long getTotalLoadTime() {
        return 0;
    }

    @Override
    public double getAverageLoadPenalty() {
        return 0;
    }

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

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

    @Override
    public long getMaxTotalWeight() {
        return Long.MAX_VALUE;
    }

    @Override
    public void resetStats() {
        // ignored
    }

    Counting getPutRejectedAsCachedInSecCounter() {
        return putRejectedAsCachedInSecMeter;
    }

    //~--------------------------------------< private helpers

    private static String getStatName(String meter, String cacheName) {
        return cacheName + "." + meter;
    }

    private TimeSeries getTimeSeries(String name) {
        return statisticsProvider.getStats().getTimeSeries(name, true);
    }

    private TimeSeries getTimeSeries(String name, boolean resetValues) {
        return statisticsProvider.getStats().getTimeSeries(name, resetValues);
    }


    /**
     * TimeSeries that computes the hit ratio in percentages. ( hit/total * 100 )
     */
    private static class PercentageTimeSeries implements TimeSeries {

        private TimeSeries hit, total;

        PercentageTimeSeries(TimeSeries hit, TimeSeries total) {
            this.hit = hit;
            this.total = total;
        }

        @Override
        public long[] getValuePerSecond() {
            return percentage(hit.getValuePerSecond(), total.getValuePerSecond());
        }

        @Override
        public long[] getValuePerMinute() {
            return percentage(hit.getValuePerMinute(), total.getValuePerMinute());
        }

        @Override
        public long[] getValuePerHour() {
            return percentage(hit.getValuePerHour(), total.getValuePerHour());
        }

        @Override
        public long[] getValuePerWeek() {
            return percentage(hit.getValuePerWeek(), total.getValuePerWeek());
        }

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

        private static long[] percentage(long[] a, long[] b) {
            long[] result = new long[a.length];
            for (int i=0; i<a.length; ++i) {
                if (b[i] == 0) {
                    result[i] = 0;
                }
                else {
                    result[i] = (a[i] * 100) / b[i];
                }
            }
            return result;
        }
    }

    /**
     * TimeSeries as a difference between two other TimeSeries
     */
    private static class DifferenceTimeSeries implements TimeSeries {

        private TimeSeries tsA, tsB;

        DifferenceTimeSeries(TimeSeries tsA, TimeSeries tsB) {
            this.tsA = tsA;
            this.tsB = tsB;
        }

        @Override
        public long[] getValuePerSecond() {
            return difference(tsA.getValuePerSecond(), tsB.getValuePerSecond());
        }

        @Override
        public long[] getValuePerMinute() {
            return difference(tsA.getValuePerMinute(), tsB.getValuePerMinute());
        }

        @Override
        public long[] getValuePerHour() {
            return difference(tsA.getValuePerHour(), tsB.getValuePerHour());
        }

        @Override
        public long[] getValuePerWeek() {
            return difference(tsA.getValuePerWeek(), tsB.getValuePerWeek());
        }

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

        private static long[] difference(long[] a, long[] b) {
            long[] result = new long[a.length];
            for (int i=0; i<a.length; ++i) {
                result[i] = a[i] - b[i];
            }
            return result;
        }
    }
}