/*
 * 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.cassandra.service;

import java.io.IOException;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryUsage;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

import javax.management.MBeanServer;
import javax.management.MalformedObjectNameException;
import javax.management.Notification;
import javax.management.NotificationListener;
import javax.management.ObjectName;
import javax.management.openmbean.CompositeData;

import com.sun.management.GarbageCollectionNotificationInfo;
import com.sun.management.GcInfo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
import org.apache.cassandra.utils.MBeanWrapper;
import org.apache.cassandra.utils.StatusLogger;

import static org.apache.cassandra.utils.Clock.Global.nanoTime;

public class GCInspector implements NotificationListener, GCInspectorMXBean
{
    public static final String MBEAN_NAME = "org.apache.cassandra.service:type=GCInspector";
    private static final Logger logger = LoggerFactory.getLogger(GCInspector.class);

    /*
     * The field from java.nio.Bits that tracks the total number of allocated
     * bytes of direct memory requires via ByteBuffer.allocateDirect that have not been GCed.
     */
    final static Field BITS_TOTAL_CAPACITY;
    // The hard limit for direct memory allocation, typically controlled by the -XX:MaxDirectMemorySize JVM option.
    final static Field BITS_MAX;
    // This represents the amount of direct memory that has been reserved for future use but not necessarily allocated yet.
    final static Field BITS_RESERVED;

    static
    {
        Class<?> bitsClass = null;

        try
        {
            bitsClass = Class.forName("java.nio.Bits");
        }
        catch (Throwable t)
        {
            logger.debug("Error accessing field of java.nio.Bits", t);
        }

        if (bitsClass != null)
        {
            BITS_TOTAL_CAPACITY = getField(bitsClass, "TOTAL_CAPACITY");
            // Returns the maximum amount of allocatable direct buffer memory.
            BITS_MAX = getField(bitsClass, "MAX_MEMORY");
            BITS_RESERVED = getField(bitsClass, "RESERVED_MEMORY");
        }
        else
        {
            BITS_TOTAL_CAPACITY = null;
            BITS_MAX = null;
            BITS_RESERVED = null;
        }
    }

    static final class State
    {
        final double maxRealTimeElapsed;
        final double totalRealTimeElapsed;
        final double sumSquaresRealTimeElapsed;
        final double totalBytesReclaimed;
        final double count;
        final long startNanos;

        State(double extraElapsed, double extraBytes, State prev)
        {
            this.totalRealTimeElapsed = prev.totalRealTimeElapsed + extraElapsed;
            this.totalBytesReclaimed = prev.totalBytesReclaimed + extraBytes;
            this.sumSquaresRealTimeElapsed = prev.sumSquaresRealTimeElapsed + (extraElapsed * extraElapsed);
            this.startNanos = prev.startNanos;
            this.count = prev.count + 1;
            this.maxRealTimeElapsed = Math.max(prev.maxRealTimeElapsed, extraElapsed);
        }

        State()
        {
            count = maxRealTimeElapsed = sumSquaresRealTimeElapsed = totalRealTimeElapsed = totalBytesReclaimed = 0;
            startNanos = nanoTime();
        }
    }

    static final class GCState
    {
        final GarbageCollectorMXBean gcBean;
        final boolean assumeGCIsPartiallyConcurrent;
        final boolean assumeGCIsOldGen;
        final boolean isZGC;
        private String[] keys;
        long lastGcTotalDuration = 0;


        GCState(GarbageCollectorMXBean gcBean, boolean assumeGCIsPartiallyConcurrent, boolean assumeGCIsOldGen, boolean isZGC)
        {
            this.gcBean = gcBean;
            this.assumeGCIsPartiallyConcurrent = assumeGCIsPartiallyConcurrent;
            this.assumeGCIsOldGen = assumeGCIsOldGen;
            this.isZGC = isZGC;
        }

        String[] keys(GarbageCollectionNotificationInfo info)
        {
            if (keys != null)
                return keys;

            keys = info.getGcInfo().getMemoryUsageBeforeGc().keySet().toArray(new String[0]);
            Arrays.sort(keys);

            return keys;
        }

        public boolean isZGC()
        {
            return isZGC;
        }
    }

    final AtomicReference<State> state = new AtomicReference<>(new State());

    final Map<String, GCState> gcStates = new HashMap<>();

    public GCInspector()
    {
        try
        {
            ObjectName gcName = new ObjectName(ManagementFactory.GARBAGE_COLLECTOR_MXBEAN_DOMAIN_TYPE + ",*");
            for (ObjectName name : MBeanWrapper.instance.queryNames(gcName, null))
            {
                GarbageCollectorMXBean gc = ManagementFactory.newPlatformMXBeanProxy(MBeanWrapper.instance.getMBeanServer(), name.getCanonicalName(), GarbageCollectorMXBean.class);
                gcStates.put(gc.getName(), new GCState(gc, assumeGCIsPartiallyConcurrent(gc), assumeGCIsOldGen(gc), isZGC(gc)));
            }
            ObjectName me = new ObjectName(MBEAN_NAME);
            if (!MBeanWrapper.instance.isRegistered(me))
                MBeanWrapper.instance.registerMBean(this, new ObjectName(MBEAN_NAME));
        }
        catch (MalformedObjectNameException | IOException e)
        {
            throw new RuntimeException(e);
        }
    }

    public static void register() throws Exception
    {
        GCInspector inspector = new GCInspector();
        MBeanServer server = ManagementFactory.getPlatformMBeanServer();
        ObjectName gcName = new ObjectName(ManagementFactory.GARBAGE_COLLECTOR_MXBEAN_DOMAIN_TYPE + ",*");
        for (ObjectName name : server.queryNames(gcName, null))
        {
            logger.info("Notification listener is added for: {}", name);
            server.addNotificationListener(name, inspector, null, null);
        }
    }

    private static boolean isZGC(GarbageCollectorMXBean gc)
    {
        return gc.getName().contains("ZGC");
    }

    /*
     * Assume that a GC type is at least partially concurrent and so a side channel method
     * should be used to calculate application stopped time due to the GC.
     *
     * If the GC isn't recognized then assume that is concurrent and we need to do our own calculation
     * via the side channel.
     */
    private static boolean assumeGCIsPartiallyConcurrent(GarbageCollectorMXBean gc)
    {
        switch (gc.getName())
        {
                // First two are from the serial collector
            case "Copy":
            case "MarkSweepCompact":
                // Parallel collector
            case "PS MarkSweep":
            case "PS Scavenge":
            case "G1 Young Generation":
                // CMS young generation collector
            case "ParNew":
                // gen zgc
            case "ZGC Minor Pauses":
            case "ZGC Major Pauses":
                // zgc
            case "ZGC Pauses":
                return false;
            case "ConcurrentMarkSweep":
            case "G1 Old Generation":
            case "ZGC Minor Cycles":
            case "ZGC Major Cycles":
            case "ZGC Cycles":
                return true;
            default:
                // Assume possibly concurrent if unsure
                return true;
        }
    }

    /*
     * Assume that a GC type is an old generation collection so TransactionLogs.rescheduleFailedTasks()
     * should be invoked.
     *
     * Defaults to not invoking TransactionLogs.rescheduleFailedTasks() on unrecognized GC names
     */
    private static boolean assumeGCIsOldGen(GarbageCollectorMXBean gc)
    {
        switch (gc.getName())
        {
            case "Copy":
            case "PS Scavenge":
            case "G1 Young Generation":
            case "ParNew":
            case "ZGC Minor Cycles":
            case "ZGC Minor Pauses":
                return false;
            case "MarkSweepCompact":
            case "PS MarkSweep":
            case "ConcurrentMarkSweep":
            case "G1 Old Generation":
            case "ZGC Major Cycles":
            case "ZGC Major Pauses":
                // assume non-generational zgc is old gen
            case "ZGC Cycles":
            case "ZGC Pauses":
                return true;
            default:
                // Assume not old gen otherwise, don't call
                // TransactionLogs.rescheduleFailedTasks()
                return false;
        }
    }

    public void handleNotification(final Notification notification, final Object handback)
    {
        String type = notification.getType();
        if (type.equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION))
        {
            // retrieve the garbage collection notification information
            CompositeData cd = (CompositeData) notification.getUserData();
            GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(cd);
            String gcName = info.getGcName();
            GcInfo gcInfo = info.getGcInfo();

            long duration = gcInfo.getDuration();

            /*
             * The duration supplied in the notification info includes more than just
             * application stopped time for concurrent GCs. Try and do a better job coming up with a good stopped time
             * value by asking for and tracking cumulative time spent blocked in GC.
             */
            GCState gcState = gcStates.get(gcName);
            if (gcState.assumeGCIsPartiallyConcurrent)
            {
                long previousTotal = gcState.lastGcTotalDuration;
                long total = gcState.gcBean.getCollectionTime();
                gcState.lastGcTotalDuration = total;
                duration = total - previousTotal; // may be zero for a really fast collection
            }

            logger.trace("Received GarbageCollectionNotificationInfo with gcId: {}, gcName: {}, " +
                         "gcCause: {}, gcAction: {}, event duration: {} ms, calculated duration: {} ms",
                         gcInfo.getId(), info.getGcName(), info.getGcCause(), info.getGcAction(),
                         gcInfo.getDuration(), duration);

            StringBuilder sb = new StringBuilder();
            sb.append(info.getGcName()).append(" GC in ").append(duration).append("ms.  ");
            long bytes = 0;
            Map<String, MemoryUsage> beforeMemoryUsage = gcInfo.getMemoryUsageBeforeGc();
            Map<String, MemoryUsage> afterMemoryUsage = gcInfo.getMemoryUsageAfterGc();
            for (String key : gcState.keys(info))
            {
                MemoryUsage before = beforeMemoryUsage.get(key);
                MemoryUsage after = afterMemoryUsage.get(key);
                if (after != null && after.getUsed() != before.getUsed())
                {
                    sb.append(key).append(": ").append(before.getUsed());
                    sb.append(" -> ");
                    sb.append(after.getUsed());
                    if (!key.equals(gcState.keys[gcState.keys.length - 1]))
                        sb.append("; ");
                    bytes += before.getUsed() - after.getUsed();
                }
            }

            while (true)
            {
                State prev = state.get();
                if (state.compareAndSet(prev, new State(duration, bytes, prev)))
                    break;
            }

            if (isConcurrentPhase(info.getGcCause(), info.getGcName()))
            {
                if (getGcConcurrentPhaseWarnThresholdInMs() != 0 && duration > getGcConcurrentPhaseWarnThresholdInMs())
                    logger.warn(sb.toString());
                else if (duration > getGcConcurrentPhaseLogThresholdInMs())
                    logger.info(sb.toString());
                else if (logger.isTraceEnabled())
                    logger.trace(sb.toString());

                if (duration > this.getConcurrentStatusThresholdInMs())
                    StatusLogger.log();
            }
            else
            {
                if (getGcWarnThresholdInMs() != 0 && duration > getGcWarnThresholdInMs())
                    logger.warn(sb.toString());
                else if (duration > getGcLogThresholdInMs())
                    logger.info(sb.toString());
                else if (logger.isTraceEnabled())
                    logger.trace(sb.toString());

                if (duration > this.getStatusThresholdInMs())
                    StatusLogger.log();
            }

            // if we just finished an old gen collection and we're still using a lot of memory, try to reduce the pressure
            if (gcState.assumeGCIsOldGen)
                LifecycleTransaction.rescheduleFailedDeletions();
        }
    }

    static boolean isConcurrentPhase(String cause, String name) {
        // Mostly taken from: https://github.com/Netflix/spectator/blob/v1.7.x/spectator-ext-gc/src/main/java/com/netflix/spectator/gc/GcLogger.java
        // So far the only indicator known is that the cause will be reported as "No GC"
        // when using CMS.
        //
        // For ZGC, behavior was changed in JDK17: https://bugs.openjdk.java.net/browse/JDK-8265136
        // For ZGC in older versions, there is no way to accurately get the amount of time
        // in STW pauses.
        //
        // For G1, a new bean is added in JDK20 to indicate time spent in concurrent phases:
        // https://bugs.openjdk.org/browse/JDK-8297247

        return "No GC".equals(cause)                // CMS
               || "G1 Concurrent GC".equals(name)   // G1 in JDK20+
               || name.endsWith(" Cycles");         // Shenandoah, ZGC
    }


    public State getTotalSinceLastCheck()
    {
        return state.getAndSet(new State());
    }

    public double[] getAndResetStats()
    {
        State state = getTotalSinceLastCheck();
        double[] r = new double[9];
        r[0] = TimeUnit.NANOSECONDS.toMillis(nanoTime() - state.startNanos);
        r[1] = state.maxRealTimeElapsed;
        r[2] = state.totalRealTimeElapsed;
        r[3] = state.sumSquaresRealTimeElapsed;
        r[4] = state.totalBytesReclaimed;
        r[5] = state.count;
        r[6] = getTotalDirectMemory();
        r[7] = getMaxDirectMemory();
        r[8] = getReservedDirectMemory();

        return r;
    }

    private static long getTotalDirectMemory()
    {
        return getFieldValue(BITS_TOTAL_CAPACITY, true);
    }

    private static long getMaxDirectMemory()
    {
        return getFieldValue(BITS_MAX, false);
    }

    private static long getReservedDirectMemory()
    {
        return getFieldValue(BITS_RESERVED, true);
    }

    private static Field getField(Class<?> clazz, String fieldName)
    {
        try
        {
            Field field = clazz.getDeclaredField(fieldName);
            field.setAccessible(true);
            return field;
        }
        catch (Throwable t)
        {
            logger.trace("Error accessing field {} of {}", fieldName, clazz.getName(), t);
            // Return null to indicate failure
            return null;
        }
    }

    /**
     * Retrieves the value of a Field, handling both regular long fields and AtomicLong fields.
     *
     * From the implementation of java.nio.Bits, we can infer that TOTAL_CAPACITY/RESERVED_MEMORY is AtomicLong
     * and MAX_MEMORY is long.
     *
     * @param field the Field to retrieve the value from
     * @param isAtomicLong true if the field is an AtomicLong, false if it's a regular long
     * @return the field value, or -1 if retrieval fails or field is null.
     */
    private static long getFieldValue(Field field, boolean isAtomicLong)
    {
        if (field == null) return -1;
        try
        {
            return isAtomicLong ? ((AtomicLong) field.get(null)).get() : field.getLong(null);
        }
        catch (Throwable t)
        {
            logger.trace("Error accessing field value of {}", field.getName(), t);
            //Don't care how or why we failed to get the value in this JVM. Return -1 to indicate failure
            return -1;
        }
    }

    /**
     * While we _technically_ could only set the threshold based on which GC we're running, there's really no
     * perceptable value-add from doing that. Responsibility for enforcing the validity of the params is entrusted
     * to the {@link DatabaseDescriptor}
     */
    @Override
    public void setGcWarnThresholdInMs(long threshold)
    {
        DatabaseDescriptor.setGCWarnThreshold(threshold);
    }

    public long getGcWarnThresholdInMs()
    {
        return DatabaseDescriptor.getGCWarnThreshold();
    }

    public void setGcLogThresholdInMs(long threshold)
    {
        DatabaseDescriptor.setGCLogThreshold((int) threshold);
    }

    public int getGcConcurrentPhaseWarnThresholdInMs()
    {
        return DatabaseDescriptor.getGCConcurrentPhaseWarnThreshold();
    }

    public void setGcConcurrentPhaseWarnThresholdInMs(int threshold)
    {
        DatabaseDescriptor.setGCConcurrentPhaseWarnThreshold(threshold);
    }

    public int getGcConcurrentPhaseLogThresholdInMs()
    {
        return DatabaseDescriptor.getGCConcurrentPhaseLogThreshold();
    }

    public void setGcConcurrentPhaseLogThresholdInMs(int threshold)
    {
        DatabaseDescriptor.setGCConcurrentPhaseLogThreshold(threshold);
    }

    public long getGcLogThresholdInMs()
    {
        return DatabaseDescriptor.getGCLogThreshold();
    }

    public long getStatusThresholdInMs()
    {
        return getGcWarnThresholdInMs() != 0 ? getGcWarnThresholdInMs() : getGcLogThresholdInMs();
    }

    public long getConcurrentStatusThresholdInMs()
    {
        return getGcConcurrentPhaseWarnThresholdInMs() != 0 ? getGcConcurrentPhaseWarnThresholdInMs() : getGcConcurrentPhaseLogThresholdInMs();
    }
}
