Stamp.java

package pro.verron.officestamper;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import pro.verron.officestamper.api.OfficeStamperException;
import pro.verron.officestamper.preset.ExceptionResolvers;
import pro.verron.officestamper.preset.OfficeStamperConfigurations;

import java.io.OutputStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchKey;
import java.util.*;

import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
import static java.time.OffsetDateTime.now;
import static pro.verron.officestamper.TemplateKind.WORD;

@Command(name = "stamp")
public class Stamp implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(Main.class);

    @Option(
            names = {"-t", "--template"},
            required = true,
            description = "Template file path (.docx|.pptx), or a keyword (diagnostic) for a packaged sample template"
    )
    private String templatePath;

    @Option(
            names = {"-d", "--data"},
            required = true,
            description = "Data input: file (json|yaml|yml|properties|csv|xlsx|xml|html), a directory, or 'diagnostic'"
    )
    private String dataPath;

    @Option(
            names = {"-o", "--output"}, defaultValue = "output.docx", required = false, description = "Output file path"
    )
    private String outputPath;

    @Option(
            names = {"--dry-run"},
            required = false,
            description = "Validate template + data and variables, but do not produce the output file"
    )
    private boolean dryRun;

    @Option(
            names = {"--run-report"},
            required = false,
            description = "Optional JSON report file path with run metadata and validation results"
    )
    private boolean printRunReport;

    @Option(
            names = {"--run-report-path"},
            required = false,
            defaultValue = "run-report.json",
            description = "Optional JSON report file path with run metadata and validation results"
    )
    private String runReportPath;

    @ArgGroup(heading = "Excel options:", exclusive = false)
    private ExcelStrategy excelStrategy;

    @Option(
            names = {"--trace-report"},
            required = false,
            description = "Optional JSON traceability report file path with every placeholder resolution"
    )
    private boolean printTraceReport;

    @Option(
            names = {"--trace-report-path"},
            defaultValue = "trace-report.json",
            description = "Optional JSON traceability report file path with every placeholder resolution"
    )
    private String traceReportPath;

    @Option(
            names = {"--watch"},
            description = "Watch template and data files for changes and re-run stamping automatically"
    )
    private boolean watch;

    @Option(
            names = {"--log-format"},
            required = false,
            defaultValue = "human",
            description = "Logging format: 'human' (default) or 'json' (structured logs to stdout)"
    )
    private String logFormat;


    @Option(names = {"--bind-env"}, description = "Expose environment variables in the SpEL context as 'env'")
    private boolean bindEnv;

    private static void writeReport(Object reportData, Path path) {
        try {
            var mapper = SerializationUtils.newMapper();
            var writer = mapper.writerWithDefaultPrettyPrinter();
            try (var os = PathUtils.createOutputStream(path)) {
                writer.writeValue(os, reportData);
            }
        } catch (Exception e) {
            // Best-effort: do not fail the run because report writing failed
            logger.atWarn().setCause(e).log("Failed to write report: {}", e.getMessage());
        }
    }

    private static Object addEnv(Object context) {
        if (context instanceof Map<?, ?> map) {
            var wrapper = new LinkedHashMap<String, Object>();
            wrapper.put("env", System.getenv());
            for (var entry : map.entrySet()) {
                wrapper.put(String.valueOf(entry.getKey()), entry.getValue());
            }
            return wrapper;
        } else {
            var wrapper = new LinkedHashMap<String, Object>();
            wrapper.put("env", System.getenv());
            wrapper.put("data", context);
            return wrapper;
        }
    }

    @Override
    public void run() {
        if (watch) runWatch();
        else runOnce();
    }

    private void runWatch() {
        var lf = getLogFormat();
        lf.emit("INFO", "Watch mode enabled", null);
        try {
            runOnce();
        } catch (Exception e) {
            var error = Map.of("error", String.valueOf(e.getMessage()));
            lf.emit("ERROR", "Initial run failed", error);
        }

        try (var watchService = FileSystems.getDefault().newWatchService()) {
            var templateFile = "diagnostic".equals(templatePath) ? null : Path.of(templatePath).toAbsolutePath();
            var dataFile = "diagnostic".equals(dataPath) ? null : Path.of(dataPath).toAbsolutePath();

            var pathsToWatch = new HashSet<Path>();
            if (templateFile != null) pathsToWatch.add(templateFile);
            if (dataFile != null) pathsToWatch.add(dataFile);

            var keys = new HashMap<WatchKey, Path>();
            for (var p : pathsToWatch) {
                var dir = Files.isDirectory(p) ? p : p.getParent();
                if (dir != null && Files.exists(dir)) {
                    var key = dir.register(watchService, ENTRY_MODIFY);
                    keys.put(key, dir);
                }
            }

            while (true) {
                var key = watchService.take();
                var dir = keys.get(key);
                for (var event : key.pollEvents()) {
                    if (event.kind() == OVERFLOW) continue;
                    var context = (Path) event.context();
                    var resolved = dir.resolve(context);

                    var relevant = false;
                    for (var p : pathsToWatch) {
                        if (resolved.equals(p) || (Files.isDirectory(p) && resolved.startsWith(p))) {
                            relevant = true;
                            break;
                        }
                    }

                    if (relevant) {
                        var change = Map.of("file", resolved.toString());
                        lf.emit("INFO", "Change detected, re-stamping...", change);
                        try {
                            runOnce();
                        } catch (Exception e) {
                            var errorMessage = String.valueOf(e.getMessage());
                            var error = Map.of("error", errorMessage);
                            lf.emit("ERROR", "Re-stamping failed", error);
                        }
                    }
                }
                if (!key.reset()) break;
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (Exception e) {
            var errorMessage = String.valueOf(e.getMessage());
            var error = Map.of("error", errorMessage);
            lf.emit("ERROR", "Watch mode failed", error);
        }
    }

    private void runOnce() {
        var traceabilityReport = new TraceabilityReport(now(), templatePath, dataPath);
        // Normalize log format
        var lf = getLogFormat();

        if (templatePath.isBlank()) {
            lf.emit("ERROR", "Missing required --template path", null);
            throw new CommandLine.ParameterException(new CommandLine(this), "--template is required");
        }
        if (dataPath.isBlank() && !"diagnostic".equals(templatePath)) {
            lf.emit("ERROR", "Missing required --data when not using diagnostic " + "template", null);
            throw new CommandLine.ParameterException(new CommandLine(this),
                    "--data is required when template != diagnostic"
            );
        }

        lf.emit("INFO",
                "Start",
                Map.of("template", templatePath, "data", dataPath, "output", outputPath, "dryRun", dryRun)
        );

        try {
            var ext = "diagnostic".equals(templatePath) ? WORD : TemplateKind.templateKind(templatePath);
            // Folder semantics: each top-level file is its own context and
            // yields one output;
            // each top-level subfolder merges its files (recursively) into a
            // bigger context and yields one output.
            if (!dataPath.isBlank() && Files.isDirectory(Path.of(dataPath))) {
                var items = Contextualizer.contextualizeDir(Path.of(dataPath), excelStrategy);
                var results = new ArrayList<RunResult>(items.size());
                int idx = 0;
                for (var item : items) {
                    idx++;
                    lf.emit("INFO",
                            "Processing item",
                            Map.of("index", idx, "name", item.name(), "total", items.size())
                    );

                    var outputFilePath = PathUtils.computeOutputPath(outputPath,
                            item.name(),
                            (ext == WORD) ? ".docx" : ".pptx"
                    );
                    try (var templateStream = "diagnostic".equals(templatePath) ? Diagnostic.template() : PathUtils.streamFile(
                            Path.of(templatePath)); var out = dryRun ? OutputStream.nullOutputStream() : PathUtils.createOutputStream(
                            outputFilePath)) {
                        var context = wrapContext(item.context(), bindEnv);
                        var configuration = OfficeStamperConfigurations.standard();
                        configuration.setTraceabilityReporter(traceabilityReport);
                        if (dryRun) {
                            configuration.setExceptionResolver(ExceptionResolvers.throwing());
                            ext.stamp(templateStream, context, configuration, out);
                            results.add(new RunResult(item.name(), "ok", null, null));
                        } else {
                            ext.stamp(templateStream, context, configuration, out);
                            results.add(new RunResult(item.name(), "ok", outputFilePath.toString(), null));
                        }

                    } catch (Exception ex) {
                        lf.emit("ERROR", "Item failed", Map.of("name", item.name(), "error", ex.getMessage()));
                        results.add(new RunResult(item.name(), "error", null, ex.getMessage()));
                        // Continue with next item; overall exit code should
                        // be non-zero if any failed
                    }
                }
                var anyError = results.stream().anyMatch(r -> "error".equals(r.status()));
                if (dryRun) lf.emit("INFO",
                        "Validation completed (dry-run)",
                        Map.of("items", results.size(), "errors", anyError)
                );
                else lf.emit("INFO", "Stamping completed", Map.of("items", results.size(), "errors", anyError));
                if (printRunReport) writeReport(createResultReport(results), Path.of(runReportPath));
                if (anyError) throw new OfficeStamperException("One or more items " + "failed");
                return;
            }

            // Single context path
            final var context = wrapContext("diagnostic".equals(dataPath) ? Diagnostic.context() : Contextualizer.contextualize(
                            excelStrategy,
                            Path.of(dataPath)
                    ), bindEnv
            );
            try (var templateStream = "diagnostic".equals(templatePath) ? Diagnostic.template() : PathUtils.streamFile(
                    Path.of(templatePath))) {
                var configuration = OfficeStamperConfigurations.standard();
                configuration.setTraceabilityReporter(traceabilityReport);
                if (dryRun) {
                    // Validate: fail on unresolved placeholders but do not
                    // write any file
                    configuration.setExceptionResolver(ExceptionResolvers.throwing());
                    ext.stamp(templateStream, context, configuration, OutputStream.nullOutputStream());
                    lf.emit("INFO", "Validation successful (dry-run)", null);
                    Object reportData = createStatusReport("ok", null);
                    if (printRunReport) writeReport(reportData, Path.of(runReportPath));
                    if (printTraceReport) writeReport(traceabilityReport, Path.of(traceReportPath));
                    return;
                }

                // Real stamping (single file)
                try (var outputStream = PathUtils.createOutputStream(Path.of(outputPath))) {
                    ext.stamp(templateStream, context, configuration, outputStream);
                }
            }

            lf.emit("INFO", "Stamping completed", Map.of("output", outputPath));
            Object reportData = createStatusReport("ok", null);
            if (printRunReport) writeReport(reportData, Path.of(runReportPath));
            if (printTraceReport) writeReport(traceabilityReport, Path.of(traceReportPath));
        } catch (Exception e) {
            lf.emit("ERROR", e.getMessage(), Map.of("exception", e.getClass().getSimpleName()));
            Object reportData = createStatusReport("error", e.getMessage());
            if (printRunReport) writeReport(reportData, Path.of(runReportPath));
            // Re-throw to ensure non-zero exit code from picocli
            throw (e instanceof RuntimeException re) ? re : new OfficeStamperException(e);
        }
    }

    private Emitter getLogFormat() {
        var logFormatValue = logFormat.trim().toLowerCase();
        return switch (logFormatValue) {
            case "json" -> new JsonEmitter();
            default -> new LogEmitter();
        };
    }

    private Object wrapContext(Object context, boolean addEnv) {
        return addEnv ? addEnv(context) : context;
    }

    private Object createResultReport(List<RunResult> results) {
        var report = new LinkedHashMap<String, Object>();
        var anyError = results.stream().anyMatch(r -> "error".equals(r.status()));
        report.put("status", anyError ? "error" : "ok");
        report.put("template", templatePath);
        report.put("data", dataPath);
        report.put("dryRun", dryRun);
        report.put("timestamp", now().toString());
        var items = new ArrayList<Map<String, Object>>();
        for (var r : results) {
            var it = new LinkedHashMap<String, Object>();
            it.put("name", r.name());
            it.put("status", r.status());
            if (r.output() != null) it.put("output", r.output());
            if (r.error() != null) it.put("error", r.error());
            items.add(it);
        }
        report.put("items", items);
        return report;
    }

    private Object createStatusReport(String status, @Nullable String errorMessage) {
        var report = new LinkedHashMap<String, Object>();
        report.put("status", status);
        report.put("template", templatePath);
        report.put("data", dataPath);
        report.put("output", outputPath);
        report.put("dryRun", dryRun);
        report.put("timestamp", now().toString());
        if (errorMessage != null) report.put("error", errorMessage);
        return report;
    }
}