CSVResultFormatter.java

package org.example.apiInteraction.resultFormatting;

import org.jetbrains.annotations.NotNull;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;

public class CSVResultFormatter implements CustomFormatter {
    private static final DateTimeFormatter ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx");
    private String sourceName;
    private final ObjectMapper mapper;

    public CSVResultFormatter(String sourceName) {
        this.sourceName = sourceName;
        this.mapper = new ObjectMapper();
    }


    @Override
    public String format(String jsonPayload, String existingContent) {
        CsvData existing = parseExisting(existingContent);

        int nextId = maxId(existing.rows()) + 1;

        JsonNode data = mapper.readTree(jsonPayload);
        ObjectNode envelope = buildEnvelope(data, nextId);

        List<LinkedHashMap<String, String>> newRows = flattenNode(envelope, "");

        LinkedHashSet<String> mergedHeaders = new LinkedHashSet<>(existing.headers());
        newRows.forEach(row -> mergedHeaders.addAll(row.keySet()));

        List<Map<String, String>> allRows = new ArrayList<>(existing.rows());
        allRows.addAll(newRows);

        return serializeCsv(new ArrayList<>(mergedHeaders), allRows);
    }

    public void setSourceName(String sourceName) {
        this.sourceName = sourceName;
    }

    private @NotNull ObjectNode buildEnvelope(JsonNode data, int id) {
        ObjectNode envelope = mapper.createObjectNode();
        envelope.put("id", id);
        envelope.put("source", sourceName);
        envelope.put("timestamp", currentTimestamp());
        envelope.set("data", data);
        return envelope;
    }

    private List<LinkedHashMap<String, String>> flattenNode(@NotNull JsonNode node, String prefix) {
        if (node.isObject()) {
            List<LinkedHashMap<String, String>> result = new ArrayList<>();
            result.add(new LinkedHashMap<>());

            for (Map.Entry<String, JsonNode> entry : node.properties()) {
                String childPath = prefix.isEmpty()
                        ? entry.getKey()
                        : prefix + "." + entry.getKey();

                List<LinkedHashMap<String, String>> childRows = flattenNode(entry.getValue(), childPath);

                if (childRows.size() == 1) {
                    // non-expanding field: merge into every current row
                    for (LinkedHashMap<String, String> row : result) {
                        row.putAll(childRows.getFirst());
                    }
                } else {
                    // array expansion: cross-product existing rows * child rows
                    List<LinkedHashMap<String, String>> next =
                            new ArrayList<>(result.size() * childRows.size());
                    for (LinkedHashMap<String, String> existingRow : result) {
                        for (LinkedHashMap<String, String> childRow : childRows) {
                            LinkedHashMap<String, String> merged = new LinkedHashMap<>(existingRow);
                            merged.putAll(childRow);
                            next.add(merged);
                        }
                    }
                    result = next;
                }
            }
            return result;

        } else if (node.isArray()) {
            boolean hasComplexElements = false;
            for (JsonNode elem : node) {
                if (elem.isObject() || elem.isArray()) { hasComplexElements = true; break; }
            }

            if (hasComplexElements) {
                // object array: explode - each element contributes its own rows
                List<LinkedHashMap<String, String>> rows = new ArrayList<>();
                for (JsonNode element : node) {
                    rows.addAll(flattenNode(element, prefix));
                }
                return rows;
            } else {
                // primitive array: collapse into a single "|"-delimited cell
                StringJoiner joiner = new StringJoiner("|");
                for (JsonNode elem : node) {
                    joiner.add(elem.isNull() ? "" : elem.asString());
                }
                LinkedHashMap<String, String> row = new LinkedHashMap<>();
                row.put(prefix, joiner.toString());
                return List.of(row);
            }

        } else {
            // primitive or null
            LinkedHashMap<String, String> row = new LinkedHashMap<>();
            row.put(prefix, node.isNull() ? "" : node.asString());
            return List.of(row);
        }
    }

    private record CsvData(List<String> headers, List<Map<String, String>> rows) {
        static CsvData empty() {
            return new CsvData(new ArrayList<>(), new ArrayList<>());
        }
    }

    private @NotNull CsvData parseExisting(String content) {
        if (content == null || content.isBlank()) return CsvData.empty();

        String[] lines = content.split("\r?\n", -1);
        if (lines.length == 0) return CsvData.empty();

        List<String> headers = parseCsvLine(lines[0]);
        List<Map<String, String>> rows = new ArrayList<>();

        for (int i = 1; i < lines.length; i++) {
            if (lines[i].isBlank()) continue;
            List<String> values = parseCsvLine(lines[i]);
            Map<String, String> row = new LinkedHashMap<>();
            for (int j = 0; j < headers.size(); j++) {
                row.put(headers.get(j), j < values.size() ? values.get(j) : "");
            }
            rows.add(row);
        }
        return new CsvData(headers, rows);
    }

    private int maxId(@NotNull List<Map<String, String>> rows) {
        return rows.stream()
                .mapToInt(r -> {
                    try { return Integer.parseInt(r.getOrDefault("id", "0")); }
                    catch (NumberFormatException e) { return 0; }
                })
                .max()
                .orElse(0);
    }

    private @NotNull String serializeCsv(@NotNull List<String> headers, @NotNull List<Map<String, String>> rows) {
        StringBuilder sb = new StringBuilder();

        StringJoiner headerLine = new StringJoiner(",");
        headers.forEach(h -> headerLine.add(escapeCsv(h)));
        sb.append(headerLine).append("\n");

        for (Map<String, String> row : rows) {
            StringJoiner dataLine = new StringJoiner(",");
            for (String header : headers) {
                dataLine.add(escapeCsv(row.getOrDefault(header, "")));
            }
            sb.append(dataLine).append("\n");
        }

        return sb.toString();
    }

    private @NotNull List<String> parseCsvLine(@NotNull String line) {
        List<String> fields = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        boolean inQuotes = false;

        for (int i = 0; i < line.length(); i++) {
            char c = line.charAt(i);
            if (c == '"') {
                if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
                    current.append('"');
                    i++;
                } else {
                    inQuotes = !inQuotes;
                }
            } else if (c == ',' && !inQuotes) {
                fields.add(current.toString());
                current = new StringBuilder();
            } else {
                current.append(c);
            }
        }
        fields.add(current.toString());
        return fields;
    }

    private @NotNull String escapeCsv(String value) {
        if (value == null || value.isEmpty()) return "";
        if (value.contains(",") || value.contains("\"")
                || value.contains("\n") || value.contains("\r")) {
            return "\"" + value.replace("\"", "\"\"") + "\"";
        }
        return value;
    }

    private @NotNull String currentTimestamp() {
        return OffsetDateTime.now(ZoneOffset.UTC).format(ISO8601);
    }

}