/*
 * Copyright 2015 M. Isuru Tharanga Chrishantha Perera
 *
 * 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.github.chrishantha.jfr.flamegraph.output;

import com.beust.jcommander.Parameter;
import com.jrockit.mc.common.IMCFrame;
import com.jrockit.mc.common.IMCMethod;
import com.jrockit.mc.flightrecorder.FlightRecording;
import com.jrockit.mc.flightrecorder.FlightRecordingLoader;
import com.jrockit.mc.flightrecorder.internal.model.FLRStackTrace;
import com.jrockit.mc.flightrecorder.spi.IEvent;
import com.jrockit.mc.flightrecorder.spi.ITimeRange;
import com.jrockit.mc.flightrecorder.spi.IView;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
import java.text.MessageFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Stack;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPInputStream;

/**
 * Parse JFR dump and create a compatible output for Flame Graph
 */
public final class JFRToFlameGraphWriter {

    private final OutputWriterParameters parameters;
    @Parameter(names = {"-h", "--help"}, description = "Display Help", help = true)
    boolean help;

    @Parameter(names = {"-f", "--jfrdump"}, description = "Java Flight Recorder Dump", required = true)
    File jfrdump;

    @Parameter(names = {"-ot", "--output-type"}, description = "Output type")
    OutputType outputType = OutputType.FOLDED;

    @Parameter(names = {"-o", "--output"}, description = "Output file")
    File outputFile;

    @Parameter(names = {"-d", "--decompress"}, description = "Decompress the JFR file")
    boolean decompress;

    @Parameter(names = {"-i", "--ignore-line-numbers"}, description = "Ignore Line Numbers in Stack Frame")
    boolean ignoreLineNumbers;

    @Parameter(names = {"-rv", "--show-return-value"}, description = "Show return value for methods in the stack")
    boolean showReturnValue;

    @Parameter(names = {"-sn",
            "--use-simple-names"}, description = "Use simple names instead of qualified names in the stack")
    boolean useSimpleNames;

    @Parameter(names = {"-ha", "--hide-arguments"}, description = "Hide arguments in methods")
    boolean hideArguments;

    @Parameter(names = {"-j", "--print-jfr-details"}, description = "Print JFR details and exit")
    boolean printJFRDetails;

    @Parameter(names = {"-t", "--print-timestamp"}, description = "Print timestamp in JFR Details")
    boolean printTimestamp;

    @Parameter(names = {"-st", "--start-timestamp"}, description = "Start timestamp in seconds for filtering", converter = SecondsToNanosConverter.class)
    long startTimestamp = Long.MIN_VALUE;

    @Parameter(names = {"-et", "--end-timestamp"}, description = "End timestamp in seconds for filtering", converter = SecondsToNanosConverter.class)
    long endTimestamp = Long.MAX_VALUE;

    @Parameter(names = {"-e",
            "--event"}, description = "Type of event used to generate the flamegraph", converter = EventType.EventTypeConverter.class)
    EventType eventType = EventType.METHOD_PROFILING_SAMPLE;

    private static final String EVENT_VALUE_STACK = "(stackTrace)";

    private static final String PRINT_FORMAT = "%-16s: %s%n";

    private static final String DURATION_FORMAT = "{0} h {1} min";

    public JFRToFlameGraphWriter(OutputWriterParameters parameters) {
        this.parameters = parameters;
    }

    public void process() throws Exception {
        FlightRecording recording = loadRecording();

        if (printJFRDetails) {
            printJFRDetails(recording);
        } else {
            convertToStacks(recording);
        }
    }

    private FlightRecording loadRecording() throws IOException {
        FlightRecording recording;
        try {
            recording = FlightRecordingLoader.loadFile(decompress ? decompressFile(jfrdump) : jfrdump);
        } catch (Exception e) {
            System.err.println("Could not load the JFR file.");
            if (!decompress) {
                System.err.println("If the JFR file is compressed, try the decompress option");
            }
            throw e;
        }
        return recording;
    }

    private void convertToStacks(FlightRecording recording) throws IOException {
        IView view = recording.createView();

        FlameGraphOutputWriter flameGraphOutputWriter = outputType.createFlameGraphOutputWriter();
        flameGraphOutputWriter.initialize(parameters);

        view.setFilter(eventType::matches);

        for (IEvent event : view) {
            if (!matchesTimeRange(event)) {
                continue;
            }

            FLRStackTrace flrStackTrace = (FLRStackTrace) event.getValue(EVENT_VALUE_STACK);
            if (flrStackTrace != null) {
                Stack<String> stack = getStack(event);
                long value = eventType.getValue(event);
                flameGraphOutputWriter.processEvent(event.getStartTimestamp(), event.getEndTimestamp(), event.getDuration(), stack, value);
            }
        }

        try (Writer writer = outputFile != null ? new FileWriter(outputFile) : new PrintWriter(System.out);
             BufferedWriter bufferedWriter = new BufferedWriter(writer)) {
            flameGraphOutputWriter.writeOutput(bufferedWriter);
        }
    }

    private boolean matchesTimeRange(IEvent event) {
        long eventStartTimestamp = event.getStartTimestamp();
        long eventEndTimestamp = event.getEndTimestamp();
        if (eventStartTimestamp >= startTimestamp && eventStartTimestamp <= endTimestamp) {
            return true;
        } else if (eventEndTimestamp >= startTimestamp && eventEndTimestamp <= endTimestamp) {
            return true;
        }
        return false;
    }

    private void printJFRDetails(FlightRecording recording) {
        ITimeRange timeRange = recording.getTimeRange();

        long startTimestamp = TimeUnit.NANOSECONDS.toSeconds(timeRange.getStartTimestamp());
        long endTimestamp = TimeUnit.NANOSECONDS.toSeconds(timeRange.getEndTimestamp());

        Duration d = Duration.ofNanos(timeRange.getDuration());
        long hours = d.toHours();
        long minutes = d.minusHours(hours).toMinutes();

        IView view = recording.createView();

        long minEventStartTimestamp = Long.MAX_VALUE;
        long maxEventEndTimestamp = 0;

        view.setFilter(eventType::matches);

        for (IEvent event : view) {
            long eventStartTimestamp = event.getStartTimestamp();
            long eventEndTimestamp = event.getEndTimestamp();
            if (eventStartTimestamp < minEventStartTimestamp) {
                minEventStartTimestamp = eventStartTimestamp;
            }

            if (eventEndTimestamp > maxEventEndTimestamp) {
                maxEventEndTimestamp = eventEndTimestamp;
            }
        }

        Duration eventsDuration = Duration.ofNanos(maxEventEndTimestamp - minEventStartTimestamp);
        long eventHours = eventsDuration.toHours();
        long eventMinutes = eventsDuration.minusHours(eventHours).toMinutes();

        minEventStartTimestamp = TimeUnit.NANOSECONDS.toSeconds(minEventStartTimestamp);
        maxEventEndTimestamp = TimeUnit.NANOSECONDS.toSeconds(maxEventEndTimestamp);

        System.out.println("JFR Details");
        if (printTimestamp) {
            System.out.format(PRINT_FORMAT, "Start", startTimestamp);
            System.out.format(PRINT_FORMAT, "End", endTimestamp);
            System.out.format(PRINT_FORMAT, "Min Start Event", minEventStartTimestamp);
            System.out.format(PRINT_FORMAT, "Max End Event", maxEventEndTimestamp);
        } else {
            Instant startInstant = Instant.ofEpochSecond(startTimestamp);
            Instant endInstant = Instant.ofEpochSecond(endTimestamp);
            Instant minStartInstant = Instant.ofEpochSecond(minEventStartTimestamp);
            Instant maxEndInstant = Instant.ofEpochSecond(maxEventEndTimestamp);
            DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
                    .withZone(ZoneId.systemDefault());
            System.out.format(PRINT_FORMAT, "Start", formatter.format(startInstant));
            System.out.format(PRINT_FORMAT, "End", formatter.format(endInstant));
            System.out.format(PRINT_FORMAT, "Min Start Event", formatter.format(minStartInstant));
            System.out.format(PRINT_FORMAT, "Max End Event", formatter.format(maxEndInstant));
        }
        System.out.format(PRINT_FORMAT, "JFR Duration", MessageFormat.format(DURATION_FORMAT, hours, minutes));
        System.out.format(PRINT_FORMAT, "Events Duration",
                MessageFormat.format(DURATION_FORMAT, eventHours, eventMinutes));
    }

    private Stack<String> getStack(IEvent event) {
        FLRStackTrace flrStackTrace = (FLRStackTrace) event.getValue(EVENT_VALUE_STACK);
        Stack<String> stack = new Stack<>();
        if (flrStackTrace == null) {
            return stack;
        }
        for (IMCFrame frame : flrStackTrace.getFrames()) {
            String frameName = getFrameName(frame);
            if (frameName != null) {
                stack.push(frameName);
            }
        }
        return stack;
    }

    private String getFrameName(IMCFrame frame) {
        StringBuilder methodBuilder = new StringBuilder();
        IMCMethod method = frame.getMethod();
        if (method == null) {
            return null;
        }

        methodBuilder.append(method.getHumanReadable(showReturnValue, !useSimpleNames, true, !useSimpleNames, !hideArguments, !useSimpleNames));
        if (!ignoreLineNumbers) {
            methodBuilder.append(":");
            methodBuilder.append(frame.getFrameLineNumber());
        }
        return methodBuilder.toString();
    }

    private File decompressFile(final File compressedFile) throws IOException {
        byte[] buffer = new byte[8 * 1024];

        File decompressedFile;

        try (GZIPInputStream compressedStream = new GZIPInputStream(new FileInputStream(compressedFile));
             FileOutputStream uncompressedFileStream = new FileOutputStream(
                     decompressedFile = File.createTempFile("jfr_", null))) {

            decompressedFile.deleteOnExit();
            int numberOfBytes;

            while ((numberOfBytes = compressedStream.read(buffer)) > 0) {
                uncompressedFileStream.write(buffer, 0, numberOfBytes);
            }
        }

        return decompressedFile;
    }

}