001/*
002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.benchmarking.common.report;
017
018import com.google.gson.Gson;
019import com.google.gson.GsonBuilder;
020import com.google.gson.JsonObject;
021import com.google.gson.JsonSyntaxException;
022import de.cuioss.tools.logging.CuiLogger;
023
024import java.io.IOException;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.util.*;
028import java.util.stream.Stream;
029
030import static de.cuioss.benchmarking.common.constants.BenchmarkConstants.Report.Badge.TrendDirection.STABLE;
031import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN;
032
033/**
034 * Processes historical benchmark data for time-series analysis and trend visualization.
035 * <p>
036 * This processor specializes in time-series analysis of benchmark results:
037 * <ul>
038 *   <li>Loading and managing historical benchmark data</li>
039 *   <li>Detecting performance trends over time</li>
040 *   <li>Preparing data for trend visualization charts</li>
041 *   <li>Tracking changes between benchmark runs</li>
042 * </ul>
043 * <p>
044 * Use this class when you need to analyze benchmark performance over time.
045 * For pure statistical computations, use {@link StatisticsCalculator}.
046 * For processing individual benchmark results, use {@link MetricsComputer}.
047 * 
048 * @see StatisticsCalculator for statistical computations (mean, median, std dev, etc.)
049 * @see MetricsComputer for processing individual benchmark results
050 */
051public class TrendDataProcessor {
052
053    private static final CuiLogger LOGGER = new CuiLogger(TrendDataProcessor.class);
054    private static final int MAX_HISTORY_ENTRIES = 10;
055    private static final double STABILITY_THRESHOLD = 0.02; // 2% change threshold for "stable"
056    private static final double EWMA_LAMBDA = 0.25; // Decay factor for exponential weighting (standard for performance monitoring)
057    private static final String KEY_THROUGHPUT = "throughput";
058    private static final String KEY_LATENCY = "latency";
059
060    private final Gson gson = new GsonBuilder()
061            .setPrettyPrinting()
062            .create();
063
064    /**
065     * Represents a single historical data point.
066     */
067    public record HistoricalDataPoint(String timestamp, double throughput, double latency,
068    double performanceScore, String commitSha) {
069    }
070
071    /**
072     * Represents calculated trend metrics.
073     */
074    public record TrendMetrics(String direction, double changePercentage, double movingAverage,
075    double throughputTrend, double latencyTrend) {
076    }
077
078    /**
079     * Loads historical data files from the history directory.
080     * 
081     * @param historyDir path to the history directory
082     * @return list of historical benchmark data points, sorted by timestamp (newest first)
083     */
084    public List<HistoricalDataPoint> loadHistoricalData(Path historyDir) {
085        if (!Files.exists(historyDir)) {
086            return List.of();
087        }
088
089        List<HistoricalDataPoint> dataPoints = new ArrayList<>();
090
091        try (Stream<Path> pathStream = Files.list(historyDir)) {
092            List<Path> historyFiles = pathStream
093                    .filter(Files::isRegularFile)
094                    .filter(p -> p.toString().endsWith(".json"))
095                    .sorted(Comparator.reverseOrder()) // Newest first
096                    .limit(MAX_HISTORY_ENTRIES)
097                    .toList();
098
099            for (Path file : historyFiles) {
100                processHistoryFile(file, dataPoints);
101            }
102        } catch (IOException e) {
103            LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "loading historical data");
104        }
105
106        return dataPoints;
107    }
108
109    /**
110     * Processes a single history file and adds its data point to the list.
111     *
112     * @param file the history file to process
113     * @param dataPoints the list to add the data point to
114     */
115    private void processHistoryFile(Path file, List<HistoricalDataPoint> dataPoints) {
116        try {
117            String jsonContent = Files.readString(file);
118            JsonObject data = gson.fromJson(jsonContent, JsonObject.class);
119
120            HistoricalDataPoint point = extractDataPoint(data, file.getFileName().toString());
121            if (point != null) {
122                dataPoints.add(point);
123            }
124        } catch (IOException | JsonSyntaxException e) {
125            LOGGER.warn(e, WARN.ISSUE_PARSING_HISTORY_FILE, file);
126        }
127    }
128
129    /**
130     * Calculates trend metrics from historical data using EWMA (Exponentially Weighted Moving Average).
131     * <p>
132     * EWMA provides a weighted baseline that emphasizes recent performance while still considering
133     * historical context. This prevents false negatives when comparing identical consecutive runs
134     * after a major performance shift.
135     *
136     * @param currentMetrics current benchmark metrics
137     * @param historicalData previous benchmark results (ordered newest first)
138     * @return trend metrics with analysis
139     */
140    public TrendMetrics calculateTrends(BenchmarkMetrics currentMetrics,
141            List<HistoricalDataPoint> historicalData) {
142        if (historicalData.isEmpty()) {
143            return new TrendMetrics(STABLE, 0.0, currentMetrics.performanceScore(), 0.0, 0.0);
144        }
145
146        // Extract historical performance scores (newest first)
147        List<Double> historicalScores = historicalData.stream()
148                .map(HistoricalDataPoint::performanceScore)
149                .toList();
150
151        // Calculate EWMA baseline from historical data
152        double ewmaBaseline = StatisticsCalculator.calculateEWMA(historicalScores, EWMA_LAMBDA);
153
154        // Compare current score against EWMA baseline instead of just most recent run
155        double changePercentage = StatisticsCalculator.calculatePercentageChange(
156                ewmaBaseline,
157                currentMetrics.performanceScore());
158
159        // Determine trend direction
160        String direction = StatisticsCalculator.determineTrendDirection(
161                changePercentage, STABILITY_THRESHOLD * 100);
162
163        // Calculate simple moving average using last 5 runs (industry-standard window)
164        List<Double> recentScores = new ArrayList<>();
165        recentScores.add(currentMetrics.performanceScore());
166        historicalData.stream()
167                .limit(4)
168                .map(HistoricalDataPoint::performanceScore)
169                .forEach(recentScores::add);
170        double movingAverage = StatisticsCalculator.calculateMovingAverage(recentScores, 5);
171
172        // Calculate throughput and latency trends using EWMA
173        List<Double> historicalThroughput = historicalData.stream()
174                .map(HistoricalDataPoint::throughput)
175                .toList();
176        List<Double> historicalLatency = historicalData.stream()
177                .map(HistoricalDataPoint::latency)
178                .toList();
179
180        double throughputBaseline = StatisticsCalculator.calculateEWMA(historicalThroughput, EWMA_LAMBDA);
181        double latencyBaseline = StatisticsCalculator.calculateEWMA(historicalLatency, EWMA_LAMBDA);
182
183        double throughputTrend = StatisticsCalculator.calculatePercentageChange(
184                throughputBaseline,
185                currentMetrics.throughput()
186        );
187        double latencyTrend = StatisticsCalculator.calculatePercentageChange(
188                latencyBaseline,
189                currentMetrics.latency()
190        );
191
192        return new TrendMetrics(direction, changePercentage, movingAverage,
193                throughputTrend, latencyTrend);
194    }
195
196    /**
197     * Generates chart-ready trend data for visualization.
198     * 
199     * @param historicalData historical benchmark results
200     * @param currentMetrics current benchmark metrics (optional)
201     * @return map containing chart labels and datasets
202     */
203    public Map<String, Object> generateTrendChartData(List<HistoricalDataPoint> historicalData,
204            BenchmarkMetrics currentMetrics) {
205        Map<String, Object> chartData = new LinkedHashMap<>();
206
207        // Prepare data lists
208        List<String> timestamps = new ArrayList<>();
209        List<Double> throughputValues = new ArrayList<>();
210        List<Double> latencyValues = new ArrayList<>();
211        List<Double> performanceScores = new ArrayList<>();
212
213        // Add historical data (reversed to show oldest first in chart)
214        List<HistoricalDataPoint> reversedData = historicalData.reversed();
215
216        for (HistoricalDataPoint point : reversedData) {
217            timestamps.add(point.timestamp());
218            throughputValues.add(point.throughput());
219            latencyValues.add(point.latency());
220            performanceScores.add(point.performanceScore());
221        }
222
223        // Add current data if provided
224        if (currentMetrics != null) {
225            timestamps.add("Current");
226            throughputValues.add(currentMetrics.throughput());
227            latencyValues.add(currentMetrics.latency());
228            performanceScores.add(currentMetrics.performanceScore());
229        }
230
231        chartData.put("timestamps", timestamps);
232        chartData.put(KEY_THROUGHPUT, throughputValues);
233        chartData.put(KEY_LATENCY, latencyValues);
234        chartData.put("performanceScores", performanceScores);
235
236        // Add statistical analysis
237        Map<String, Object> statistics = new LinkedHashMap<>();
238        StatisticsCalculator.Statistics throughputStats = StatisticsCalculator.computeStatistics(throughputValues);
239        StatisticsCalculator.Statistics latencyStats = StatisticsCalculator.computeStatistics(latencyValues);
240
241        statistics.put("throughputMin", throughputStats.getMin());
242        statistics.put("throughputMax", throughputStats.getMax());
243        statistics.put("throughputAvg", throughputStats.getMean());
244        statistics.put("latencyMin", latencyStats.getMin());
245        statistics.put("latencyMax", latencyStats.getMax());
246        statistics.put("latencyAvg", latencyStats.getMean());
247
248        chartData.put("statistics", statistics);
249
250        return chartData;
251    }
252
253    /**
254     * Extracts a data point from JSON data.
255     */
256    private HistoricalDataPoint extractDataPoint(JsonObject data, String filename) {
257        try {
258            JsonObject metadata = data.getAsJsonObject("metadata");
259            JsonObject overview = data.getAsJsonObject("overview");
260
261            String timestamp = metadata.has("timestamp")
262                    ? metadata.get("timestamp").getAsString()
263                    : extractTimestampFromFilename(filename);
264
265            double throughput = overview.has(KEY_THROUGHPUT)
266                    ? parseMetricValue(overview.get(KEY_THROUGHPUT).getAsString())
267                    : 0.0;
268
269            double latency = overview.has(KEY_LATENCY)
270                    ? parseMetricValue(overview.get(KEY_LATENCY).getAsString())
271                    : 0.0;
272
273            double performanceScore = overview.has("performanceScore")
274                    ? overview.get("performanceScore").getAsDouble()
275                    : 0.0;
276
277            String commitSha = extractCommitFromFilename(filename);
278
279            return new HistoricalDataPoint(timestamp, throughput, latency, performanceScore, commitSha);
280        } catch (NullPointerException | ClassCastException | IllegalStateException | UnsupportedOperationException e) {
281            LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "extracting data point");
282            return null;
283        }
284    }
285
286    /**
287     * Parses a metric value from a formatted string (e.g., "1.5K ops/s" -> 1500.0).
288     */
289    private double parseMetricValue(String value) {
290        if (value == null || "N/A".equals(value)) {
291            return 0.0;
292        }
293
294        // Remove units and parse
295        String cleanValue = value.replaceAll("[^0-9.KMG]", "").trim();
296        if (cleanValue.isEmpty()) {
297            return 0.0;
298        }
299
300        double multiplier = 1.0;
301        if (cleanValue.contains("K")) {
302            multiplier = 1000.0;
303            cleanValue = cleanValue.replace("K", "");
304        } else if (cleanValue.contains("M")) {
305            multiplier = 1_000_000.0;
306            cleanValue = cleanValue.replace("M", "");
307        } else if (cleanValue.contains("G")) {
308            multiplier = 1_000_000_000.0;
309            cleanValue = cleanValue.replace("G", "");
310        }
311
312        try {
313            return Double.parseDouble(cleanValue) * multiplier;
314        } catch (NumberFormatException e) {
315            return 0.0;
316        }
317    }
318
319
320    /**
321     * Extracts timestamp from filename.
322     */
323    private String extractTimestampFromFilename(String filename) {
324        int dashIndex = filename.lastIndexOf('-');
325        if (dashIndex > 0) {
326            return filename.substring(0, dashIndex);
327        }
328        return filename;
329    }
330
331    /**
332     * Extracts commit SHA from filename.
333     */
334    private String extractCommitFromFilename(String filename) {
335        int dashIndex = filename.lastIndexOf('-');
336        int dotIndex = filename.lastIndexOf('.');
337        if (dashIndex > 0 && dotIndex > dashIndex) {
338            return filename.substring(dashIndex + 1, dotIndex);
339        }
340        return "unknown";
341    }
342}