AuditWriter.java

package org.egothor.methodatlas.gui.service;

import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.yaml.YAMLMapper;
import tools.jackson.dataformat.yaml.YAMLWriteFeature;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.emit.TagAiDrift;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Writes audit evidence after a batch "Save All Changes" operation.
 *
 * <p>Two artefacts are produced in the hidden {@code .methodatlas/}
 * subdirectory of the scanned project root:</p>
 *
 * <ol>
 *   <li><strong>Timestamped evidence CSV</strong> —
 *       {@code methodatlas-YYYYMMDD-HHmmss.csv}.  One row per saved method.
 *       The first thirteen columns are the CLI {@code DeltaReport} schema, so
 *       existing tooling (diff, import, comparison) works without modification
 *       — {@code DeltaReport} resolves columns by name and ignores any it does
 *       not recognise.  Two extra columns, {@code tags_added} and
 *       {@code tags_removed}, record the reviewer's tag changes relative to the
 *       AI suggestion.  The file is never overwritten; each Save All creates a
 *       new timestamped file, giving an append-only audit trail.</li>
 *   <li><strong>Cumulative override YAML</strong> —
 *       {@code overrides.yaml}.  Uses the {@code ClassificationOverride} YAML
 *       format consumed by the CLI {@code -override-file} flag so that future
 *       analysis runs can reproduce the same tag decisions without re-invoking
 *       the AI.  Existing entries are updated in place; new entries are
 *       appended.  The {@code note} field carries the operator name (when
 *       configured in settings) and the ISO-8601 review timestamp.</li>
 * </ol>
 *
 * <p>Both files are written after source patches have been applied.  If
 * writing fails the caller should show a warning — the source patches are
 * already on disk and must not be rolled back.</p>
 *
 * <p><strong>Schema note (4.0.0):</strong> the {@code tag_ai_drift} column now
 * follows the same definition as the CLI, {@link TagAiDrift} — agreement
 * between a source-level {@code @Tag("security")} marker and the AI
 * security-relevance verdict — rather than a comparison of the whole applied
 * and AI tag sets. The reviewer's set changes moved to the dedicated
 * {@code tags_added}/{@code tags_removed} columns. See the Migration Guide.</p>
 *
 * @see org.egothor.methodatlas.gui.model.AppSettings#getOperatorName()
 * @see TagAiDrift
 */
public final class AuditWriter {

    private static final DateTimeFormatter FILE_TS =
            DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
    private static final DateTimeFormatter NOTE_TS =
            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

    private AuditWriter() {}

    /**
     * Immutable snapshot of one saved method, captured before the staged patch
     * is cleared.
     *
     * @param fqcn               fully-qualified class name
     * @param method             simple method name
     * @param loc                lines of code (0 when unavailable)
     * @param appliedTags        tags written to the source file
     * @param appliedDisplayName display name written to source, or {@code null}
     * @param suggestion         AI suggestion at save time, or {@code null}
     */
    public record SavedEntry(
            String fqcn,
            String method,
            int loc,
            List<String> appliedTags,
            String appliedDisplayName,
            AiMethodSuggestion suggestion) {}

    /**
     * Writes the evidence CSV and updates the override YAML for all supplied
     * saved entries.
     *
     * <p>The {@code .methodatlas/} directory is created if it does not exist.
     * The evidence CSV receives a fresh timestamped name; the override YAML is
     * merged in place.</p>
     *
     * @param scannedDir   root directory that was scanned; artefacts go into
     *                     its {@code .methodatlas/} subdirectory
     * @param entries      entries that were successfully patched, in discovery
     *                     order; must not be {@code null} or empty
     * @param operatorName reviewer identifier for the {@code note} field, or
     *                     an empty string to omit it
     * @throws IOException if either output file cannot be written
     */
    public static void write(Path scannedDir, List<SavedEntry> entries,
            String operatorName) throws IOException {
        Path auditDir = scannedDir.resolve(".methodatlas");
        Files.createDirectories(auditDir);
        LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
        writeEvidenceCsv(auditDir, entries, now);
        updateOverrideYaml(auditDir, entries, operatorName, now);
    }

    // ── Evidence CSV ──────────────────────────────────────────────────────

    @SuppressWarnings("PMD.NPathComplexity")
    private static void writeEvidenceCsv(Path dir, List<SavedEntry> entries,
            LocalDateTime timestamp) throws IOException {
        Path csvFile = dir.resolve("methodatlas-" + timestamp.format(FILE_TS) + ".csv");
        try (PrintWriter w = new PrintWriter(
                Files.newBufferedWriter(csvFile, StandardCharsets.UTF_8))) {
            w.println("fqcn,method,loc,tags,display_name,content_hash,"
                    + "ai_security_relevant,ai_display_name,ai_tags,ai_reason,"
                    + "ai_interaction_score,ai_confidence,tag_ai_drift,tags_added,tags_removed");
            for (SavedEntry e : entries) {
                AiMethodSuggestion ai = e.suggestion();
                List<String> applied = e.appliedTags();
                w.println(
                        csv(e.fqcn()) + ","
                        + csv(e.method()) + ","
                        + (e.loc() > 0 ? e.loc() : "") + ","
                        + csv(applied != null ? String.join(";", applied) : "") + ","
                        + csv(e.appliedDisplayName() != null ? e.appliedDisplayName() : "") + ","
                        + /* content_hash */ ","
                        + (ai != null ? ai.securityRelevant() : "") + ","
                        + csv(ai != null && ai.displayName() != null ? ai.displayName() : "") + ","
                        + csv(ai != null && ai.tags() != null ? String.join(";", ai.tags()) : "") + ","
                        + csv(ai != null && ai.reason() != null ? ai.reason() : "") + ","
                        + (ai != null ? String.format("%.1f", ai.interactionScore()) : "") + ","
                        + (ai != null ? String.format("%.1f", ai.confidence()) : "") + ","
                        + driftValue(applied, ai) + ","
                        + csv(tagDelta(applied, ai, true)) + ","
                        + csv(tagDelta(applied, ai, false)));
            }
        }
    }

    // ── Override YAML ─────────────────────────────────────────────────────

    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> loadExistingOverrides(
            Path yamlFile, ObjectMapper mapper) throws IOException {
        List<Map<String, Object>> existing = new ArrayList<>();
        if (!Files.exists(yamlFile)) { return existing; }
        Map<String, Object> root = mapper.readValue(yamlFile.toFile(), Map.class);
        if (root == null || !(root.get("overrides") instanceof List<?> list)) { return existing; }
        for (Object item : list) {
            if (item instanceof Map<?, ?> m) {
                existing.add(new LinkedHashMap<>((Map<String, Object>) m));
            }
        }
        return existing;
    }

    @SuppressWarnings({"PMD.NPathComplexity", "PMD.AvoidInstantiatingObjectsInLoops"})
    private static void updateOverrideYaml(Path dir, List<SavedEntry> entries,
            String operatorName, LocalDateTime timestamp) throws IOException {
        Path yamlFile = dir.resolve("overrides.yaml");

        ObjectMapper mapper = YAMLMapper.builder()
                .disable(YAMLWriteFeature.WRITE_DOC_START_MARKER)
                .build();

        // Load existing overrides (if any) into a mutable list
        List<Map<String, Object>> existing = loadExistingOverrides(yamlFile, mapper);

        // Build an index keyed by "fqcn#method" for O(1) lookup
        Map<String, Integer> idx = new LinkedHashMap<>();
        for (int i = 0; i < existing.size(); i++) {
            Map<String, Object> entry = existing.get(i);
            idx.put(entry.get("fqcn") + "#" + entry.get("method"), i);
        }

        String note = buildNote(operatorName, timestamp);

        for (SavedEntry e : entries) {
            AiMethodSuggestion ai = e.suggestion();
            List<String> applied = e.appliedTags();

            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("fqcn", e.fqcn());
            entry.put("method", e.method());
            // Applying tags implies security relevance; preserve AI classification otherwise
            boolean secRel = (applied != null && !applied.isEmpty())
                    || (ai != null && ai.securityRelevant());
            entry.put("securityRelevant", secRel);
            if (applied != null && !applied.isEmpty()) {
                entry.put("tags", new ArrayList<>(applied));
            }
            if (e.appliedDisplayName() != null && !e.appliedDisplayName().isBlank()) {
                entry.put("displayName", e.appliedDisplayName());
            }
            if (ai != null && ai.reason() != null && !ai.reason().isBlank()) {
                entry.put("reason", ai.reason());
            }
            entry.put("note", note);

            String key = e.fqcn() + "#" + e.method();
            Integer existingIdx = idx.get(key);
            if (existingIdx != null) {
                existing.set(existingIdx, entry);
            } else {
                idx.put(key, existing.size());
                existing.add(entry);
            }
        }

        Map<String, Object> root = new LinkedHashMap<>();
        root.put("overrides", existing);
        mapper.writerWithDefaultPrettyPrinter().writeValue(yamlFile.toFile(), root);
    }

    // ── Helpers ───────────────────────────────────────────────────────────

    private static String buildNote(String operatorName, LocalDateTime timestamp) {
        String ts = timestamp.format(NOTE_TS);
        if (operatorName != null && !operatorName.isBlank()) {
            return "Reviewed " + ts + " by " + operatorName.trim();
        }
        return "Reviewed " + ts;
    }

    /**
     * Computes the {@code tag_ai_drift} value using the same definition as the
     * CLI ({@link TagAiDrift}): the agreement between the applied
     * {@code @Tag("security")} marker and the AI security-relevance verdict.
     *
     * @param applied tags applied to the method, or {@code null}
     * @param ai      AI suggestion, or {@code null} when AI was not run
     * @return {@code none}, {@code tag-only}, or {@code ai-only}; empty string
     *         when there is no AI suggestion to compare against
     */
    private static String driftValue(List<String> applied, AiMethodSuggestion ai) {
        if (ai == null) {
            return "";
        }
        TagAiDrift drift = TagAiDrift.compute(applied != null ? applied : List.of(), ai);
        return drift != null ? drift.toValue() : "";
    }

    /**
     * Computes the reviewer's tag changes relative to the AI suggestion, sorted
     * and joined with {@code ;} for a stable, comparable audit record.
     *
     * @param applied tags applied to the method, or {@code null}
     * @param ai      AI suggestion, or {@code null} when AI was not run
     * @param added   {@code true} for tags the reviewer added beyond the AI
     *                suggestion; {@code false} for tags the AI suggested that the
     *                reviewer did not keep
     * @return sorted, semicolon-joined tag names; empty string when there is no
     *         AI suggestion to diff against
     */
    private static String tagDelta(List<String> applied, AiMethodSuggestion ai, boolean added) {
        if (ai == null) {
            return "";
        }
        List<String> aiTags = ai.tags();
        return added ? TagAiDrift.tagDifference(applied, aiTags)
                : TagAiDrift.tagDifference(aiTags, applied);
    }

    /**
     * RFC 4180 CSV field quoting, with the same spreadsheet formula-injection
     * guard as the CLI emitter: a value whose first character is {@code =},
     * {@code +}, {@code -}, or {@code @} is quoted so a spreadsheet does not
     * evaluate it as a formula.
     *
     * @param value field value; may be {@code null}
     * @return CSV-safe representation; empty string for {@code null}
     */
    private static String csv(String value) {
        if (value == null || value.isEmpty()) { return ""; }
        boolean formulaPrefix = "=+-@".indexOf(value.charAt(0)) >= 0;
        if (formulaPrefix || value.contains(",") || value.contains("\"") || value.contains("\n")
                || value.contains("\r")) {
            return "\"" + value.replace("\"", "\"\"") + "\"";
        }
        return value;
    }
}