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 de.cuioss.benchmarking.common.util.JsonSerializationHelper;
019import de.cuioss.tools.logging.CuiLogger;
020
021import java.io.IOException;
022import java.nio.file.Files;
023import java.nio.file.Path;
024import java.time.Instant;
025import java.time.ZoneOffset;
026import java.time.format.DateTimeFormatter;
027import java.util.Comparator;
028import java.util.List;
029import java.util.Map;
030import java.util.stream.Stream;
031
032import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.INFO;
033import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN;
034
035/**
036 * Manages historical benchmark data persistence.
037 * <p>
038 * This manager handles:
039 * <ul>
040 *   <li>Saving current benchmark runs to history with timestamp-based naming</li>
041 *   <li>Maintaining a retention policy (keeping only the most recent runs)</li>
042 *   <li>Organizing historical data in a queryable directory structure</li>
043 * </ul>
044 * <p>
045 * File naming convention: {@code YYYY-MM-DD-THHMMZ-{commitSha}.json}
046 * where the timestamp is in UTC and the commit SHA is truncated to 8 characters.
047 */
048public class HistoricalDataManager {
049
050    private static final CuiLogger LOGGER = new CuiLogger(HistoricalDataManager.class);
051    private static final int RETENTION_COUNT = 10;
052    private static final String HISTORY_DIR = "history";
053    private static final String JSON_EXTENSION = ".json";
054    private static final DateTimeFormatter TIMESTAMP_FORMAT =
055            DateTimeFormatter.ofPattern("yyyy-MM-dd-'T'HHmm'Z'").withZone(ZoneOffset.UTC);
056
057
058    /**
059     * Archives the current benchmark data to the history directory.
060     *
061     * @param currentData the current benchmark data to archive
062     * @param outputDir the base output directory
063     * @param commitSha the Git commit SHA (will be truncated to 8 chars)
064     * @throws IOException if writing the archive file fails
065     */
066    public void archiveCurrentRun(Map<String, Object> currentData, String outputDir, String commitSha)
067            throws IOException {
068        Path historyDir = Path.of(outputDir, HISTORY_DIR);
069        Files.createDirectories(historyDir);
070
071        String timestamp = TIMESTAMP_FORMAT.format(Instant.now());
072        String truncatedSha = commitSha != null && commitSha.length() >= 8
073                ? commitSha.substring(0, 8)
074                : "unknown";
075        String filename = "%s-%s%s".formatted(timestamp, truncatedSha, JSON_EXTENSION);
076
077        Path archiveFile = historyDir.resolve(filename);
078        String jsonContent = JsonSerializationHelper.toJson(currentData);
079        Files.writeString(archiveFile, jsonContent);
080
081        LOGGER.info(INFO.ARCHIVED_BENCHMARK_DATA, archiveFile);
082    }
083
084    /**
085     * Enforces the retention policy by keeping only the most recent files.
086     *
087     * @param historyDir the history directory path
088     * @throws IOException if accessing or deleting files fails
089     */
090    public void enforceRetentionPolicy(Path historyDir) throws IOException {
091        if (!Files.exists(historyDir)) {
092            return;
093        }
094
095        try (Stream<Path> files = Files.list(historyDir)) {
096            List<Path> jsonFiles = files
097                    .filter(Files::isRegularFile)
098                    .filter(p -> p.toString().endsWith(JSON_EXTENSION))
099                    .sorted(Comparator.comparing(this::extractTimestamp).reversed())
100                    .toList();
101
102            if (jsonFiles.size() > RETENTION_COUNT) {
103                List<Path> filesToDelete = jsonFiles.subList(RETENTION_COUNT, jsonFiles.size());
104                for (Path file : filesToDelete) {
105                    Files.delete(file);
106                    LOGGER.info(INFO.REMOVED_HISTORY_FILE, file.getFileName());
107                }
108            }
109        }
110    }
111
112    /**
113     * Retrieves a sorted list of historical data files.
114     *
115     * @param historyDir the history directory path
116     * @return sorted list of historical data files (newest first)
117     * @throws IOException if reading the directory fails
118     */
119    public List<Path> getHistoricalFiles(Path historyDir) throws IOException {
120        if (!Files.exists(historyDir)) {
121            return List.of();
122        }
123
124        try (Stream<Path> files = Files.list(historyDir)) {
125            return files
126                    .filter(Files::isRegularFile)
127                    .filter(p -> p.toString().endsWith(JSON_EXTENSION))
128                    .sorted(Comparator.comparing(this::extractTimestamp).reversed())
129                    .toList();
130        }
131    }
132
133    /**
134     * Checks if historical data is available.
135     *
136     * @param outputDir the base output directory
137     * @return true if historical data exists, false otherwise
138     */
139    public boolean hasHistoricalData(String outputDir) {
140        Path historyDir = Path.of(outputDir, HISTORY_DIR);
141        if (!Files.exists(historyDir)) {
142            return false;
143        }
144
145        try (Stream<Path> files = Files.list(historyDir)) {
146            return files
147                    .filter(Files::isRegularFile)
148                    .anyMatch(p -> p.toString().endsWith(JSON_EXTENSION));
149        } catch (IOException e) {
150            LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "checking historical data");
151            return false;
152        }
153    }
154
155    /**
156     * Extracts the timestamp from a historical data filename.
157     *
158     * @param file the file path
159     * @return the timestamp string, or empty string if extraction fails
160     */
161    private String extractTimestamp(Path file) {
162        String filename = file.getFileName().toString();
163        int dashIndex = filename.lastIndexOf('-');
164        if (dashIndex > 0) {
165            return filename.substring(0, dashIndex);
166        }
167        return "";
168    }
169}