DecisionRationaleDocxRenderer.java

package com.taxonomy.architecture.decision;

import com.taxonomy.architecture.decision.DecisionChapterDiagramRenderer.DiagramPanel;
import com.taxonomy.architecture.decision.DecisionRationaleReport.ChildDecision;
import com.taxonomy.architecture.decision.DecisionRationaleReport.DecisionChapter;
import com.taxonomy.architecture.decision.DecisionRationaleReport.LeafCandidate;
import com.taxonomy.architecture.decision.DecisionRationaleReport.PathStep;
import com.taxonomy.extension.api.report.ReportFormatDescriptor;
import com.taxonomy.extension.api.report.ReportRenderContext;
import com.taxonomy.extension.api.report.ReportRenderResult;
import com.taxonomy.extension.api.report.ReportRendererExtension;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.model.XWPFHeaderFooterPolicy;
import org.apache.poi.xwpf.usermodel.Document;
import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
import org.apache.poi.xwpf.usermodel.TableRowAlign;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFFooter;
import org.apache.poi.xwpf.usermodel.XWPFHeader;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation;
import org.springframework.stereotype.Component;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;

/**
 * Professional DOCX renderer for the hierarchical decision rationale report.
 *
 * <p>The generated document uses A4 pages, a title page, an executive summary,
 * deterministic decision diagrams, repeating table headings where possible, and a footer
 * containing generation/account/version evidence plus PAGE/NUMPAGES fields.</p>
 */
@Component
@SuppressWarnings("serial")
public class DecisionRationaleDocxRenderer implements ReportRendererExtension {

    private static final ReportFormatDescriptor DESCRIPTOR = new ReportFormatDescriptor(
            "docx",
            "DOCX",
            "docx",
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            true);

    private static final String FONT = "Aptos";
    private static final String MONO_FONT = "Aptos Mono";
    private static final String NAVY = "0B1F33";
    private static final String TEAL = "007A78";
    private static final String LIGHT_TEAL = "E1F4F3";
    private static final String PALE_BLUE = "E9F0F7";
    private static final String LIGHT_GRAY = "F4F6F8";
    private static final String LINE = "CDD5DC";
    private static final String MUTED = "64707C";
    private static final String WARNING = "FFF4D6";
    private static final String WARNING_BORDER = "B98200";
    private static final int PAGE_WIDTH_TWIPS = 11_906;
    private static final int PAGE_HEIGHT_TWIPS = 16_838;

    private final DecisionChapterDiagramRenderer diagramRenderer;

    public DecisionRationaleDocxRenderer(DecisionChapterDiagramRenderer diagramRenderer) {
        this.diagramRenderer = diagramRenderer;
    }

    @Override
    public String reportTypeId() {
        return DecisionRationaleReportPlugin.REPORT_TYPE_ID;
    }

    @Override
    public Class<?> reportModelType() {
        return DecisionRationaleReport.class;
    }

    @Override
    public ReportFormatDescriptor descriptor() {
        return DESCRIPTOR;
    }

    @Override
    public ReportRenderResult render(ReportRenderContext context) {
        return new ReportRenderResult(render(
                context.payloadAs(DecisionRationaleReport.class)));
    }

    public byte[] render(DecisionRationaleReport report) {
        DecisionReportLabels labels = new DecisionReportLabels(report.languageTag());
        try (XWPFDocument document = new XWPFDocument();
             ByteArrayOutputStream output = new ByteArrayOutputStream()) {
            configurePage(document);
            addHeaderAndFooter(document, report, labels);
            renderTitlePage(document, report, labels);
            writeReportBody(document, report, labels);
            document.write(output);
            return output.toByteArray();
        } catch (Exception exception) {
            throw new IllegalStateException("Could not generate decision rationale DOCX", exception);
        }
    }

    /**
     * Writes the report content shared by standalone and template-backed DOCX output.
     *
     * <p>The already opened document owns its cover, headers, footers and template
     * styling. This typed boundary owns the single implementation of metadata,
     * executive-summary, chapter/table/diagram and appendix semantics.</p>
     */
    void writeReportBody(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) throws Exception {
        configureCoreProperties(document, report);
        renderExecutiveSummary(document, report, labels);
        renderChapters(document, report, labels);
        renderAppendix(document, report, labels);
    }

    private void configurePage(XWPFDocument document) {
        CTSectPr section = document.getDocument().getBody().isSetSectPr()
                ? document.getDocument().getBody().getSectPr()
                : document.getDocument().getBody().addNewSectPr();
        CTPageSz pageSize = section.isSetPgSz() ? section.getPgSz() : section.addNewPgSz();
        pageSize.setW(BigInteger.valueOf(PAGE_WIDTH_TWIPS));
        pageSize.setH(BigInteger.valueOf(PAGE_HEIGHT_TWIPS));
        pageSize.setOrient(STPageOrientation.PORTRAIT);
        CTPageMar margins = section.isSetPgMar() ? section.getPgMar() : section.addNewPgMar();
        margins.setTop(BigInteger.valueOf(1_020));
        margins.setBottom(BigInteger.valueOf(1_160));
        margins.setLeft(BigInteger.valueOf(1_050));
        margins.setRight(BigInteger.valueOf(1_050));
        margins.setHeader(BigInteger.valueOf(480));
        margins.setFooter(BigInteger.valueOf(500));
        margins.setGutter(BigInteger.ZERO);
    }

    private void configureCoreProperties(XWPFDocument document, DecisionRationaleReport report) {
        var properties = document.getProperties().getCoreProperties();
        properties.setTitle(report.title());
        properties.setSubjectProperty(report.requirement());
        properties.setCreator(report.metadata().generatedBy());
        properties.setDescription("Traceable hierarchical decision rationale generated by Taxonomy");
        properties.setKeywords("Taxonomy, decision rationale, requirements analysis, AI, traceability");
    }

    private void addHeaderAndFooter(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) {
        XWPFHeaderFooterPolicy policy = new XWPFHeaderFooterPolicy(document);
        XWPFHeader header = policy.createHeader(XWPFHeaderFooterPolicy.DEFAULT);
        XWPFParagraph headerParagraph = header.createParagraph();
        headerParagraph.setAlignment(ParagraphAlignment.RIGHT);
        headerParagraph.setSpacingAfter(0);
        XWPFRun headerRun = headerParagraph.createRun();
        headerRun.setFontFamily(FONT);
        headerRun.setFontSize(8);
        headerRun.setColor(MUTED);
        headerRun.setText(report.title() + " · " + report.metadata().taxonomyDataVersion());
        setBottomBorder(headerParagraph, LINE, 4);

        XWPFFooter footer = policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT);
        XWPFTable table = footer.createTable(1, 2);
        table.setWidth("100%");
        table.setTableAlignment(TableRowAlign.CENTER);
        removeTableBorders(table);
        XWPFTableRow row = table.getRow(0);
        XWPFTableCell left = row.getCell(0);
        XWPFTableCell right = row.getCell(1);
        left.setWidth("82%");
        right.setWidth("18%");
        clearCell(left);
        clearCell(right);

        XWPFParagraph leftParagraph = left.addParagraph();
        leftParagraph.setSpacingBefore(0);
        leftParagraph.setSpacingAfter(0);
        XWPFRun leftRun = leftParagraph.createRun();
        leftRun.setFontFamily(FONT);
        leftRun.setFontSize(7);
        leftRun.setColor(MUTED);
        leftRun.setText(footerText(report, labels));

        XWPFParagraph rightParagraph = right.addParagraph();
        rightParagraph.setAlignment(ParagraphAlignment.RIGHT);
        rightParagraph.setSpacingBefore(0);
        rightParagraph.setSpacingAfter(0);
        XWPFRun pageLabel = rightParagraph.createRun();
        pageLabel.setFontFamily(FONT);
        pageLabel.setFontSize(8);
        pageLabel.setColor(MUTED);
        pageLabel.setText(labels.page() + " ");
        addSimpleField(rightParagraph, "PAGE", "1", 8, MUTED);
        XWPFRun of = rightParagraph.createRun();
        of.setFontFamily(FONT);
        of.setFontSize(8);
        of.setColor(MUTED);
        of.setText(" " + labels.of() + " ");
        addSimpleField(rightParagraph, "NUMPAGES", "1", 8, MUTED);
        setTopBorder(leftParagraph, LINE, 4);
        setTopBorder(rightParagraph, LINE, 4);
    }

    private void renderTitlePage(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) {
        addAccentBar(document);
        spacer(document, 20);
        XWPFParagraph eyebrow = document.createParagraph();
        eyebrow.setSpacingAfter(120);
        XWPFRun eyebrowRun = eyebrow.createRun();
        eyebrowRun.setText("TAXONOMY · DECISION EVIDENCE");
        eyebrowRun.setFontFamily(FONT);
        eyebrowRun.setFontSize(10);
        eyebrowRun.setBold(true);
        eyebrowRun.setColor(TEAL);
        eyebrowRun.setCharacterSpacing(25);

        XWPFParagraph title = document.createParagraph();
        title.setSpacingBefore(0);
        title.setSpacingAfter(120);
        XWPFRun titleRun = title.createRun();
        titleRun.setText(report.title());
        titleRun.setFontFamily(FONT);
        titleRun.setFontSize(31);
        titleRun.setBold(true);
        titleRun.setColor(NAVY);

        XWPFParagraph subtitle = document.createParagraph();
        subtitle.setSpacingAfter(240);
        XWPFRun subtitleRun = subtitle.createRun();
        subtitleRun.setText(labels.subtitle());
        subtitleRun.setFontFamily(FONT);
        subtitleRun.setFontSize(14);
        subtitleRun.setColor(MUTED);

        XWPFTable statusTable = document.createTable(1, 1);
        statusTable.setWidth("42%");
        removeTableBorders(statusTable);
        XWPFTableCell statusCell = statusTable.getRow(0).getCell(0);
        shade(statusCell, report.status() == DecisionRationaleReport.ReportStatus.DRAFT_INCOMPLETE
                || report.status() == DecisionRationaleReport.ReportStatus.NO_RESULT
                ? WARNING : LIGHT_TEAL);
        setCellMargins(statusCell, 100, 130, 100, 130);
        setCellText(statusCell, statusLabel(report, labels), 10, true,
                report.status() == DecisionRationaleReport.ReportStatus.DRAFT_INCOMPLETE
                        || report.status() == DecisionRationaleReport.ReportStatus.NO_RESULT
                        ? "745400" : TEAL, ParagraphAlignment.CENTER);

        spacer(document, 18);
        addLabel(document, labels.requirement());
        XWPFTable requirement = document.createTable(1, 1);
        requirement.setWidth("100%");
        XWPFTableCell requirementCell = requirement.getRow(0).getCell(0);
        shade(requirementCell, LIGHT_GRAY);
        setCellMargins(requirementCell, 180, 220, 180, 220);
        setLeftCellBorder(requirementCell, TEAL, 22);
        setCellText(requirementCell, report.requirement(), 13, false, NAVY,
                ParagraphAlignment.LEFT);

        spacer(document, 18);
        addLabel(document, labels.taxonomyBasis());
        XWPFTable metadata = document.createTable(1, 2);
        metadata.setWidth("100%");
        metadata.setTableAlignment(TableRowAlign.CENTER);
        setMetadataRow(metadata.getRow(0), labels.dataVersion(),
                report.metadata().taxonomyDataVersion());
        addMetadataRow(metadata, labels.sourceFile(), report.metadata().taxonomyCatalogueFile());
        addMetadataRow(metadata, labels.sourceDigest(), report.metadata().taxonomyCatalogueResourceSha256());
        addMetadataRow(metadata, labels.dataDigest(), report.metadata().taxonomyDataFingerprintSha256());
        addMetadataRow(metadata, labels.hierarchyEvidence(),
                report.metadata().hierarchyFromImmutableSnapshot()
                        ? labels.frozenHierarchy() : labels.liveHierarchy());
        addMetadataRow(metadata, labels.nodes(), report.metadata().taxonomyNodeCount()
                + " / " + report.metadata().taxonomyRootCount());

        spacer(document, 12);
        addLabel(document, labels.provenance());
        XWPFTable provenance = document.createTable(1, 2);
        provenance.setWidth("100%");
        provenance.setTableAlignment(TableRowAlign.CENTER);
        setMetadataRow(provenance.getRow(0), labels.generatedAt(),
                formatInstant(report.metadata().generatedAt(), report.metadata().reportTimeZone()));
        addMetadataRow(provenance, labels.generatedBy(), report.metadata().generatedBy());
        addMetadataRow(provenance, labels.applicationVersion(),
                report.metadata().taxonomyApplicationVersion());
        addMetadataRow(provenance, labels.buildCommit(), report.metadata().taxonomyBuildCommit());
        addMetadataRow(provenance, labels.repository(), report.metadata().repositoryId());
        addMetadataRow(provenance, labels.workspace(), report.metadata().workspaceId());
        addMetadataRow(provenance, labels.branch(), report.metadata().branch());
        addMetadataRow(provenance, labels.basedOnCommit(), report.metadata().basedOnCommit());
        addMetadataRow(provenance, labels.analysisProvider(), report.metadata().analysisProvider());
        addMetadataRow(provenance, labels.analysisModel(), report.metadata().analysisModel());
        addMetadataRow(provenance, labels.analysisSnapshot(), report.metadata().analysisSnapshotId());
        addMetadataRow(provenance, labels.requirementVersion(), requirementVersion(report));
        addMetadataRow(provenance, labels.analysisCreatedAt(),
                formatInstant(report.metadata().analysisCreatedAt(), report.metadata().reportTimeZone()));
        addMetadataRow(provenance, labels.analysisCreatedBy(), report.metadata().analysisCreatedBy());
        addMetadataRow(provenance, labels.analysisDigest(),
                report.metadata().analysisSnapshotFingerprintSha256());
        addMetadataRow(provenance, labels.analysisStatus(), report.metadata().analysisStatus());

        XWPFParagraph note = document.createParagraph();
        note.setSpacingBefore(260);
        note.setSpacingAfter(0);
        XWPFRun noteRun = note.createRun();
        noteRun.setFontFamily(FONT);
        noteRun.setFontSize(8);
        noteRun.setColor(MUTED);
        noteRun.setItalic(true);
        noteRun.setText(report.executiveSummary().methodologyNote());
        addPageBreak(document);
    }

    private void renderExecutiveSummary(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) {
        addSectionHeading(document, "01", labels.executiveSummary());
        addLeadParagraph(document, report.executiveSummary().conciseConclusion());

        LeafCandidate leading = report.executiveSummary().leadingLeaf();
        if (leading != null) {
            XWPFTable banner = document.createTable(1, 2);
            banner.setWidth("100%");
            removeTableBorders(banner);
            XWPFTableCell textCell = banner.getRow(0).getCell(0);
            XWPFTableCell scoreCell = banner.getRow(0).getCell(1);
            shade(textCell, NAVY);
            shade(scoreCell, TEAL);
            setCellMargins(textCell, 180, 220, 180, 220);
            setCellMargins(scoreCell, 180, 150, 180, 150);
            clearCell(textCell);
            XWPFParagraph p = textCell.addParagraph();
            p.setSpacingAfter(40);
            XWPFRun label = p.createRun();
            label.setText(labels.leadingLeaf().toUpperCase(Locale.ROOT));
            label.setFontFamily(FONT);
            label.setFontSize(8);
            label.setBold(true);
            label.setColor("8DE0DD");
            XWPFRun name = p.createRun();
            name.addBreak();
            name.setText(leading.code() + " · " + leading.title());
            name.setFontFamily(FONT);
            name.setFontSize(14);
            name.setBold(true);
            name.setColor("FFFFFF");
            XWPFRun root = p.createRun();
            root.addBreak();
            root.setText(leading.taxonomyRoot());
            root.setFontFamily(FONT);
            root.setFontSize(9);
            root.setColor("DCE7EF");
            setCellText(scoreCell, leading.score() + " %", 24, true, "FFFFFF",
                    ParagraphAlignment.CENTER);
            scoreCell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
        }

        addHeading(document, labels.highestPath(), 2, false);
        XWPFTable pathTable = document.createTable(1, 6);
        pathTable.setWidth("100%");
        pathTable.setTableAlignment(TableRowAlign.CENTER);
        String[] pathHeaders = {
                labels.step(), labels.node(), labels.score(), labels.localShare(),
                labels.rationale(), labels.reasonSource()
        };
        setHeaderRow(pathTable.getRow(0), pathHeaders);
        for (PathStep step : report.executiveSummary().path()) {
            XWPFTableRow row = pathTable.createRow();
            setCellText(row.getCell(0), String.valueOf(step.position()), 8, false, NAVY,
                    ParagraphAlignment.CENTER);
            setCodeAndName(row.getCell(1), step.code(), step.title());
            setCellText(row.getCell(2), score(step.absoluteScore()), 8, true, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(3), percent(step.localSharePercent()), 8, false, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(4), step.reason(), 8, false, NAVY,
                    ParagraphAlignment.LEFT);
            setCellText(row.getCell(5), reasonSource(step.reasonSource(), labels), 7, false,
                    MUTED, ParagraphAlignment.LEFT);
            styleDataRow(row, step.position() % 2 == 0 ? LIGHT_GRAY : "FFFFFF");
        }

        addHeading(document, labels.methodology(), 2, false);
        addBodyParagraph(document, report.executiveSummary().methodologyNote(), false);
        if (!report.warnings().isEmpty()) {
            addWarningBox(document, labels.warnings(), report.warnings());
        }
        addPageBreak(document);
    }

    private void renderChapters(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) throws Exception {
        addSectionHeading(document, "02", labels.decisionChapters());
        addBodyParagraph(document, labels.german()
                ? "Jedes folgende Kapitel entspricht genau einem Vaterknoten, dessen direkte Kindknoten mindestens einen von Null verschiedenen Bewertungswert enthalten. Sämtliche direkten Kinder bleiben als geprüfte oder noch ungeprüfte Alternativen sichtbar."
                : "Each following chapter corresponds to one parent whose direct children contain at least one non-zero score. All direct children remain visible as evaluated or unevaluated alternatives.", false);
        addPageBreak(document);

        for (int chapterIndex = 0; chapterIndex < report.chapters().size(); chapterIndex++) {
            DecisionChapter chapter = report.chapters().get(chapterIndex);
            addChapterHeading(document, chapter, labels);
            if (chapter.parentDescription() != null && !chapter.parentDescription().isBlank()) {
                addBodyParagraph(document, chapter.parentDescription(), true);
            }

            List<DiagramPanel> panels = diagramRenderer.render(chapter, report.languageTag());
            for (DiagramPanel panel : panels) {
                XWPFParagraph imageParagraph = document.createParagraph();
                imageParagraph.setAlignment(ParagraphAlignment.CENTER);
                imageParagraph.setSpacingBefore(100);
                imageParagraph.setSpacingAfter(60);
                XWPFRun imageRun = imageParagraph.createRun();
                imageRun.addPicture(
                        new ByteArrayInputStream(panel.png()),
                        Document.PICTURE_TYPE_PNG,
                        "decision-" + chapter.parentCode() + "-" + panel.panelNumber() + ".png",
                        Units.toEMU(6.45 * 72),
                        Units.toEMU(3.87 * 72));
                setLastPictureAltText(imageRun, panel.altText());
                XWPFParagraph caption = document.createParagraph();
                caption.setAlignment(ParagraphAlignment.RIGHT);
                caption.setSpacingAfter(120);
                XWPFRun captionRun = caption.createRun();
                captionRun.setFontFamily(FONT);
                captionRun.setFontSize(7);
                captionRun.setItalic(true);
                captionRun.setColor(MUTED);
                captionRun.setText(labels.parentNode() + " " + chapter.parentCode()
                        + (panel.panelCount() > 1
                        ? " · " + panel.panelNumber() + "/" + panel.panelCount() : ""));
            }

            addRationaleBox(document, labels.decisionResult(), chapter.decisionSummary(), PALE_BLUE);
            addRationaleBox(document, labels.comparison(), chapter.comparativeRationale(), LIGHT_TEAL);
            addHeading(document, labels.alternatives(), 2, false);
            addChildTable(document, chapter, labels);
            if (!chapter.complete()) {
                addWarningBox(document,
                        labels.german() ? "Unvollständige Entscheidung" : "Incomplete decision",
                        List.of((labels.german()
                                ? "Nicht bewertete direkte Kinder: "
                                : "Unevaluated direct children: ")
                                + String.join(", ", chapter.missingChildCodes())));
            }
            if (chapterIndex < report.chapters().size() - 1) {
                addPageBreak(document);
            }
        }
        addPageBreak(document);
    }

    private void renderAppendix(
            XWPFDocument document,
            DecisionRationaleReport report,
            DecisionReportLabels labels) {
        addSectionHeading(document, "03", labels.appendix());
        addHeading(document, labels.leadingLeaves(), 2, false);
        XWPFTable leafTable = document.createTable(1, 6);
        leafTable.setWidth("100%");
        setHeaderRow(leafTable.getRow(0), new String[]{
                labels.rank(), labels.node(), labels.score(), labels.taxonomyRoot(),
                labels.hierarchyPath(), labels.rationale()
        });
        int rank = 1;
        for (LeafCandidate leaf : report.leadingLeaves()) {
            XWPFTableRow row = leafTable.createRow();
            setCellText(row.getCell(0), String.valueOf(rank), 8, false, NAVY,
                    ParagraphAlignment.CENTER);
            setCodeAndName(row.getCell(1), leaf.code(), leaf.title());
            setCellText(row.getCell(2), leaf.score() + " %", 8, true, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(3), leaf.taxonomyRoot(), 8, false, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(4), leaf.hierarchyPath(), 7, false, NAVY,
                    ParagraphAlignment.LEFT);
            setCellText(row.getCell(5), leaf.reason(), 7, false, NAVY,
                    ParagraphAlignment.LEFT);
            styleDataRow(row, rank % 2 == 0 ? LIGHT_GRAY : "FFFFFF");
            rank++;
        }

        addHeading(document, labels.taxonomyBasis(), 2, false);
        XWPFTable evidence = document.createTable(1, 2);
        evidence.setWidth("100%");
        setMetadataRow(evidence.getRow(0), labels.catalogue(),
                report.metadata().taxonomyDataSource());
        addMetadataRow(evidence, labels.dataVersion(), report.metadata().taxonomyDataVersion());
        addMetadataRow(evidence, labels.sourceFile(), report.metadata().taxonomyCatalogueFile());
        addMetadataRow(evidence, labels.sourceDigest(), report.metadata().taxonomyCatalogueResourceSha256());
        addMetadataRow(evidence, labels.dataDigest(), report.metadata().taxonomyDataFingerprintSha256());
        addMetadataRow(evidence, labels.recordedTaxonomyDigest(),
                report.metadata().recordedTaxonomyFingerprintSha256());
        addMetadataRow(evidence, labels.promptDigest(), report.metadata().promptFingerprintSha256());
        addMetadataRow(evidence, labels.analysisDigest(),
                report.metadata().analysisSnapshotFingerprintSha256());
        addMetadataRow(evidence, labels.analysisSnapshot(), report.metadata().analysisSnapshotId());
        addMetadataRow(evidence, labels.requirementVersion(), requirementVersion(report));
        addMetadataRow(evidence, labels.analysisCreatedAt(),
                formatInstant(report.metadata().analysisCreatedAt(), report.metadata().reportTimeZone()));
        addMetadataRow(evidence, labels.analysisCreatedBy(), report.metadata().analysisCreatedBy());
        addMetadataRow(evidence, labels.repository(), report.metadata().repositoryId());
        addMetadataRow(evidence, labels.workspace(), report.metadata().workspaceId());
        addMetadataRow(evidence, labels.branch(), report.metadata().branch());
        addMetadataRow(evidence, labels.basedOnCommit(), report.metadata().basedOnCommit());
        addMetadataRow(evidence, labels.analysisProvider(), report.metadata().analysisProvider());
        addMetadataRow(evidence, labels.analysisModel(), report.metadata().analysisModel());
        addMetadataRow(evidence, labels.analysisStatus(), report.metadata().analysisStatus());
        addMetadataRow(evidence, labels.completeness(),
                percent(report.metadata().completenessPercent()));

        addHeading(document, labels.methodology(), 2, false);
        addBodyParagraph(document, report.executiveSummary().methodologyNote(), false);
        if (!report.discrepancies().isEmpty()) {
            addHeading(document,
                    labels.german() ? "Dokumentierte Bewertungsabweichungen"
                            : "Documented scoring discrepancies",
                    2,
                    false);
            for (Object discrepancy : report.discrepancies()) {
                addBullet(document, String.valueOf(discrepancy));
            }
        }
        if (!report.warnings().isEmpty()) {
            addWarningBox(document, labels.warnings(), report.warnings());
        }
    }

    private void addChapterHeading(
            XWPFDocument document,
            DecisionChapter chapter,
            DecisionReportLabels labels) {
        XWPFParagraph number = document.createParagraph();
        number.setSpacingAfter(20);
        XWPFRun numberRun = number.createRun();
        numberRun.setText(labels.chapter().toUpperCase(Locale.ROOT) + " " + chapter.number());
        numberRun.setFontFamily(FONT);
        numberRun.setFontSize(9);
        numberRun.setBold(true);
        numberRun.setColor(TEAL);

        XWPFTable heading = document.createTable(1, 2);
        heading.setWidth("100%");
        removeTableBorders(heading);
        XWPFTableCell titleCell = heading.getRow(0).getCell(0);
        XWPFTableCell scoreCell = heading.getRow(0).getCell(1);
        clearCell(titleCell);
        XWPFParagraph title = titleCell.addParagraph();
        title.setSpacingAfter(80);
        XWPFRun code = title.createRun();
        code.setText(chapter.parentCode());
        code.setFontFamily(MONO_FONT);
        code.setFontSize(19);
        code.setBold(true);
        code.setColor(TEAL);
        XWPFRun name = title.createRun();
        name.setText(" · " + chapter.parentTitle());
        name.setFontFamily(FONT);
        name.setFontSize(19);
        name.setBold(true);
        name.setColor(NAVY);
        setBottomBorder(title, NAVY, 16);
        shade(scoreCell, TEAL);
        setCellText(scoreCell, score(chapter.parentScore()), 18, true, "FFFFFF",
                ParagraphAlignment.CENTER);
        scoreCell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
    }

    private void addChildTable(
            XWPFDocument document,
            DecisionChapter chapter,
            DecisionReportLabels labels) {
        XWPFTable table = document.createTable(1, 7);
        table.setWidth("100%");
        setHeaderRow(table.getRow(0), new String[]{
                labels.rank(), labels.node(), labels.score(), labels.localShare(),
                labels.disposition(), labels.rationale(), labels.reasonSource()
        });
        int rowNumber = 0;
        for (ChildDecision child : chapter.children()) {
            XWPFTableRow row = table.createRow();
            setCellText(row.getCell(0), child.rank() == null ? "—" : child.rank().toString(),
                    7, false, NAVY, ParagraphAlignment.CENTER);
            setCodeAndName(row.getCell(1), child.code(), child.title());
            setCellText(row.getCell(2), score(child.absoluteScore()), 7, true, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(3), percent(child.localSharePercent()), 7, false, NAVY,
                    ParagraphAlignment.CENTER);
            setCellText(row.getCell(4), disposition(child, labels), 7, false, NAVY,
                    ParagraphAlignment.LEFT);
            setCellText(row.getCell(5), child.reason(), 7, false, NAVY,
                    ParagraphAlignment.LEFT);
            setCellText(row.getCell(6), reasonSource(child.reasonSource(), labels), 6, false,
                    MUTED, ParagraphAlignment.LEFT);
            String fill;
            if (child.leadingSibling()) {
                fill = LIGHT_TEAL;
            } else if (child.disposition() == DecisionRationaleReport.Disposition.REJECTED
                    || child.disposition() == DecisionRationaleReport.Disposition.NOT_EVALUATED) {
                fill = LIGHT_GRAY;
            } else {
                fill = rowNumber % 2 == 0 ? "FFFFFF" : "F8FAFB";
            }
            styleDataRow(row, fill);
            rowNumber++;
        }
    }

    private void addAccentBar(XWPFDocument document) {
        XWPFTable bar = document.createTable(1, 2);
        bar.setWidth("100%");
        removeTableBorders(bar);
        shade(bar.getRow(0).getCell(0), NAVY);
        shade(bar.getRow(0).getCell(1), TEAL);
        bar.getRow(0).getCell(0).setWidth("72%");
        bar.getRow(0).getCell(1).setWidth("28%");
        setCellMargins(bar.getRow(0).getCell(0), 40, 0, 40, 0);
        setCellMargins(bar.getRow(0).getCell(1), 40, 0, 40, 0);
        clearCell(bar.getRow(0).getCell(0));
        clearCell(bar.getRow(0).getCell(1));
        // Word table cells must contain at least one paragraph.
        bar.getRow(0).getCell(0).addParagraph().setSpacingAfter(0);
        bar.getRow(0).getCell(1).addParagraph().setSpacingAfter(0);
    }

    private void addSectionHeading(XWPFDocument document, String number, String title) {
        XWPFParagraph kicker = document.createParagraph();
        kicker.setSpacingAfter(20);
        XWPFRun kickerRun = kicker.createRun();
        kickerRun.setText(number);
        kickerRun.setFontFamily(FONT);
        kickerRun.setFontSize(10);
        kickerRun.setBold(true);
        kickerRun.setColor(TEAL);
        XWPFParagraph heading = document.createParagraph();
        heading.setSpacingBefore(0);
        heading.setSpacingAfter(220);
        XWPFRun run = heading.createRun();
        run.setText(title);
        run.setFontFamily(FONT);
        run.setFontSize(23);
        run.setBold(true);
        run.setColor(NAVY);
        setBottomBorder(heading, NAVY, 12);
    }

    private void addHeading(
            XWPFDocument document,
            String text,
            int level,
            boolean pageBreakBefore) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setPageBreak(pageBreakBefore);
        if (paragraph.getCTP().getPPr() == null) {
            paragraph.getCTP().addNewPPr();
        }
        paragraph.setKeepNext(true);
        paragraph.setSpacingBefore(level == 2 ? 240 : 160);
        paragraph.setSpacingAfter(100);
        XWPFRun run = paragraph.createRun();
        run.setText(text);
        run.setFontFamily(FONT);
        run.setBold(true);
        run.setColor(NAVY);
        run.setFontSize(level == 2 ? 15 : 12);
    }

    private void addLabel(XWPFDocument document, String label) {
        XWPFParagraph paragraph = document.createParagraph();
        if (paragraph.getCTP().getPPr() == null) {
            paragraph.getCTP().addNewPPr();
        }
        paragraph.setKeepNext(true);
        paragraph.setSpacingBefore(80);
        paragraph.setSpacingAfter(40);
        XWPFRun run = paragraph.createRun();
        run.setText(label.toUpperCase(Locale.ROOT));
        run.setFontFamily(FONT);
        run.setFontSize(8);
        run.setBold(true);
        run.setColor(TEAL);
        run.setCharacterSpacing(15);
    }

    private void addLeadParagraph(XWPFDocument document, String text) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setSpacingAfter(180);
        setLineSpacing(paragraph, 1.15);
        XWPFRun run = paragraph.createRun();
        run.setText(text);
        run.setFontFamily(FONT);
        run.setFontSize(13);
        run.setColor(NAVY);
    }

    private void addBodyParagraph(XWPFDocument document, String text, boolean muted) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setSpacingAfter(110);
        setLineSpacing(paragraph, 1.08);
        XWPFRun run = paragraph.createRun();
        run.setText(text == null ? "" : text);
        run.setFontFamily(FONT);
        run.setFontSize(9);
        run.setColor(muted ? MUTED : "26303A");
    }

    private void addRationaleBox(
            XWPFDocument document,
            String heading,
            String body,
            String fill) {
        XWPFTable table = document.createTable(1, 1);
        table.setWidth("100%");
        removeTableBorders(table);
        XWPFTableCell cell = table.getRow(0).getCell(0);
        shade(cell, fill);
        setTopCellBorder(cell, TEAL, 18);
        setCellMargins(cell, 150, 180, 150, 180);
        clearCell(cell);
        XWPFParagraph headingParagraph = cell.addParagraph();
        headingParagraph.setSpacingAfter(50);
        XWPFRun headingRun = headingParagraph.createRun();
        headingRun.setText(heading);
        headingRun.setFontFamily(FONT);
        headingRun.setFontSize(10);
        headingRun.setBold(true);
        headingRun.setColor(NAVY);
        XWPFParagraph bodyParagraph = cell.addParagraph();
        bodyParagraph.setSpacingAfter(0);
        XWPFRun bodyRun = bodyParagraph.createRun();
        bodyRun.setText(body);
        bodyRun.setFontFamily(FONT);
        bodyRun.setFontSize(9);
        bodyRun.setColor("26303A");
        spacer(document, 6);
    }

    private void addWarningBox(XWPFDocument document, String heading, List<String> warnings) {
        XWPFTable table = document.createTable(1, 1);
        table.setWidth("100%");
        XWPFTableCell cell = table.getRow(0).getCell(0);
        shade(cell, WARNING);
        setLeftCellBorder(cell, WARNING_BORDER, 18);
        setCellMargins(cell, 140, 180, 140, 180);
        clearCell(cell);
        XWPFParagraph title = cell.addParagraph();
        title.setSpacingAfter(50);
        XWPFRun titleRun = title.createRun();
        titleRun.setText(heading);
        titleRun.setFontFamily(FONT);
        titleRun.setFontSize(10);
        titleRun.setBold(true);
        titleRun.setColor("745400");
        for (String warning : warnings) {
            XWPFParagraph bullet = cell.addParagraph();
            bullet.setIndentationLeft(220);
            bullet.setFirstLineIndent(-160);
            bullet.setSpacingAfter(40);
            XWPFRun run = bullet.createRun();
            run.setText("• " + warning);
            run.setFontFamily(FONT);
            run.setFontSize(8);
            run.setColor("4F3C00");
        }
    }

    private void addMetadataRow(XWPFTable table, String label, String value) {
        setMetadataRow(table.createRow(), label, value);
    }

    private void setMetadataRow(XWPFTableRow row, String label, String value) {
        setCellText(row.getCell(0), label, 8, true, NAVY, ParagraphAlignment.LEFT);
        setCellText(row.getCell(1), value, 8, false, "26303A", ParagraphAlignment.LEFT);
        shade(row.getCell(0), LIGHT_GRAY);
        shade(row.getCell(1), "FFFFFF");
        setCellMargins(row.getCell(0), 80, 110, 80, 110);
        setCellMargins(row.getCell(1), 80, 110, 80, 110);
        setCellBorders(row.getCell(0), LINE, 3);
        setCellBorders(row.getCell(1), LINE, 3);
    }

    private void setHeaderRow(XWPFTableRow row, String[] headers) {
        for (int index = 0; index < headers.length; index++) {
            XWPFTableCell cell = row.getCell(index);
            shade(cell, NAVY);
            setCellMargins(cell, 80, 80, 80, 80);
            setCellText(cell, headers[index], 7, true, "FFFFFF", ParagraphAlignment.LEFT);
        }
        row.setRepeatHeader(true);
    }

    private void setCodeAndName(XWPFTableCell cell, String code, String name) {
        clearCell(cell);
        XWPFParagraph paragraph = cell.addParagraph();
        paragraph.setSpacingAfter(0);
        XWPFRun codeRun = paragraph.createRun();
        codeRun.setText(code);
        codeRun.setFontFamily(MONO_FONT);
        codeRun.setFontSize(8);
        codeRun.setBold(true);
        codeRun.setColor(NAVY);
        XWPFRun nameRun = paragraph.createRun();
        nameRun.addBreak();
        nameRun.setText(name);
        nameRun.setFontFamily(FONT);
        nameRun.setFontSize(7);
        nameRun.setColor(MUTED);
    }

    private void setCellText(
            XWPFTableCell cell,
            String value,
            int fontSize,
            boolean bold,
            String color,
            ParagraphAlignment alignment) {
        clearCell(cell);
        XWPFParagraph paragraph = cell.addParagraph();
        paragraph.setAlignment(alignment);
        paragraph.setSpacingBefore(0);
        paragraph.setSpacingAfter(0);
        XWPFRun run = paragraph.createRun();
        run.setText(value == null || value.isBlank() ? "—" : value);
        run.setFontFamily(FONT);
        run.setFontSize(fontSize);
        run.setBold(bold);
        run.setColor(color);
    }

    private void styleDataRow(XWPFTableRow row, String fill) {
        for (XWPFTableCell cell : row.getTableCells()) {
            shade(cell, fill);
            setCellMargins(cell, 70, 70, 70, 70);
            setCellBorders(cell, LINE, 2);
            cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.TOP);
        }
        try {
            row.setCantSplitRow(true);
        } catch (Exception ignored) {
            // Older POI builds may not expose the convenience method.
        }
    }

    private void clearCell(XWPFTableCell cell) {
        while (!cell.getParagraphs().isEmpty()) {
            cell.removeParagraph(0);
        }
    }

    private void shade(XWPFTableCell cell, String color) {
        CTTcPr properties = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
        CTShd shading = properties.isSetShd() ? properties.getShd() : properties.addNewShd();
        shading.setFill(color);
    }

    private void setCellMargins(
            XWPFTableCell cell,
            int top,
            int left,
            int bottom,
            int right) {
        CTTcPr properties = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
        var margins = properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar();
        if (!margins.isSetTop()) margins.addNewTop();
        if (!margins.isSetLeft()) margins.addNewLeft();
        if (!margins.isSetBottom()) margins.addNewBottom();
        if (!margins.isSetRight()) margins.addNewRight();
        margins.getTop().setW(BigInteger.valueOf(top));
        margins.getLeft().setW(BigInteger.valueOf(left));
        margins.getBottom().setW(BigInteger.valueOf(bottom));
        margins.getRight().setW(BigInteger.valueOf(right));
    }

    private void setCellBorders(XWPFTableCell cell, String color, int size) {
        CTTcPr properties = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
        var borders = properties.isSetTcBorders()
                ? properties.getTcBorders() : properties.addNewTcBorders();
        configureBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop(), color, size);
        configureBorder(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(), color, size);
        configureBorder(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(), color, size);
        configureBorder(borders.isSetRight() ? borders.getRight() : borders.addNewRight(), color, size);
    }

    private void setLeftCellBorder(XWPFTableCell cell, String color, int size) {
        CTTcPr properties = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
        var borders = properties.isSetTcBorders()
                ? properties.getTcBorders() : properties.addNewTcBorders();
        configureBorder(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(), color, size);
    }

    private void setTopCellBorder(XWPFTableCell cell, String color, int size) {
        CTTcPr properties = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
        var borders = properties.isSetTcBorders()
                ? properties.getTcBorders() : properties.addNewTcBorders();
        configureBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop(), color, size);
    }

    private void removeTableBorders(XWPFTable table) {
        CTTbl tableXml = table.getCTTbl();
        CTTblPr properties = tableXml.getTblPr();
        if (properties == null) {
            properties = tableXml.addNewTblPr();
        }
        CTTblBorders borders = properties.isSetTblBorders()
                ? properties.getTblBorders() : properties.addNewTblBorders();
        noBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop());
        noBorder(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom());
        noBorder(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft());
        noBorder(borders.isSetRight() ? borders.getRight() : borders.addNewRight());
        noBorder(borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH());
        noBorder(borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV());
    }

    private void noBorder(CTBorder border) {
        border.setVal(STBorder.NIL);
    }

    private void configureBorder(CTBorder border, String color, int size) {
        border.setVal(STBorder.SINGLE);
        border.setColor(color);
        border.setSz(BigInteger.valueOf(size));
    }

    private void setBottomBorder(XWPFParagraph paragraph, String color, int size) {
        CTPPr properties = paragraph.getCTP().isSetPPr()
                ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr();
        CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr();
        configureBorder(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(), color, size);
    }

    private void setTopBorder(XWPFParagraph paragraph, String color, int size) {
        CTPPr properties = paragraph.getCTP().isSetPPr()
                ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr();
        CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr();
        configureBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop(), color, size);
    }

    private void addSimpleField(
            XWPFParagraph paragraph,
            String instruction,
            String fallback,
            int fontSize,
            String color) {
        CTSimpleField field = paragraph.getCTP().addNewFldSimple();
        field.setInstr(instruction);
        var runXml = field.addNewR();
        var properties = runXml.addNewRPr();
        properties.addNewColor().setVal(color);
        properties.addNewSz().setVal(BigInteger.valueOf(fontSize * 2L));
        CTText text = runXml.addNewT();
        text.setStringValue(fallback);
    }

    private void setLastPictureAltText(XWPFRun run, String altText) {
        try {
            var pictures = run.getEmbeddedPictures();
            if (!pictures.isEmpty()) {
                var nonVisual = pictures.get(pictures.size() - 1)
                        .getCTPicture().getNvPicPr().getCNvPr();
                nonVisual.setDescr(altText);
                nonVisual.setName("Decision diagram");
            }
        } catch (Exception ignored) {
            // The image remains visible if a particular POI schema build cannot set alt text.
        }
    }

    private void addBullet(XWPFDocument document, String text) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setIndentationLeft(260);
        paragraph.setFirstLineIndent(-170);
        paragraph.setSpacingAfter(50);
        XWPFRun run = paragraph.createRun();
        run.setText("• " + text);
        run.setFontFamily(FONT);
        run.setFontSize(8);
        run.setColor("26303A");
    }

    private void addPageBreak(XWPFDocument document) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setPageBreak(true);
        paragraph.setSpacingAfter(0);
    }

    private void spacer(XWPFDocument document, int points) {
        XWPFParagraph paragraph = document.createParagraph();
        paragraph.setSpacingAfter(points * 20);
        paragraph.createRun().setText("");
    }

    private void setLineSpacing(XWPFParagraph paragraph, double multiplier) {
        CTPPr properties = paragraph.getCTP().isSetPPr()
                ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr();
        var spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing();
        spacing.setLine(BigInteger.valueOf(Math.round(240 * multiplier)));
        spacing.setLineRule(STLineSpacingRule.AUTO);
    }

    private String statusLabel(DecisionRationaleReport report, DecisionReportLabels labels) {
        return switch (report.status()) {
            case FINAL -> labels.finalStatus();
            case FINAL_WITH_WARNINGS -> labels.finalWarnings();
            case DRAFT_INCOMPLETE -> labels.draft();
            case NO_RESULT -> labels.noResult();
        };
    }

    private String disposition(ChildDecision child, DecisionReportLabels labels) {
        return switch (child.disposition()) {
            case CONTINUED -> labels.continued();
            case LEAF_CANDIDATE -> labels.leafCandidate();
            case REJECTED -> labels.rejected();
            case NOT_EVALUATED -> labels.notEvaluated();
        };
    }

    private String reasonSource(
            DecisionRationaleReport.ReasonSource source,
            DecisionReportLabels labels) {
        if (source == null) {
            return labels.missingReason();
        }
        return switch (source) {
            case AI_SCORING -> labels.aiReason();
            case DETERMINISTIC_TRACE -> labels.deterministicReason();
            case MISSING -> labels.missingReason();
        };
    }

    private String requirementVersion(DecisionRationaleReport report) {
        if (report.metadata().requirementVersionNumber() == null) {
            return "—";
        }
        StringBuilder value = new StringBuilder("v")
                .append(report.metadata().requirementVersionNumber());
        if (report.metadata().requirementId() != null) {
            value.append(" · requirement ").append(report.metadata().requirementId());
        }
        if (report.metadata().projectId() != null) {
            value.append(" · project ").append(report.metadata().projectId());
        }
        return value.toString();
    }

    private String footerText(DecisionRationaleReport report, DecisionReportLabels labels) {
        return labels.generatedNotice() + " · "
                + formatInstant(report.metadata().generatedAt(), report.metadata().reportTimeZone())
                + " · " + report.metadata().generatedBy()
                + " · Taxonomy " + report.metadata().taxonomyApplicationVersion()
                + " · " + report.metadata().taxonomyDataVersion();
    }

    private String formatInstant(Instant instant, String zone) {
        if (instant == null) {
            return "—";
        }
        ZoneId zoneId;
        try {
            zoneId = ZoneId.of(zone);
        } catch (Exception ignored) {
            zoneId = ZoneOffset.UTC;
        }
        return DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss z", Locale.GERMANY)
                .withZone(zoneId)
                .format(instant);
    }

    private String score(Integer value) {
        return value == null ? "—" : value + " %";
    }

    private String percent(Double value) {
        if (value == null) {
            return "—";
        }
        return Math.abs(value - Math.rint(value)) < 0.05
                ? String.format(Locale.ROOT, "%.0f %%", value)
                : String.format(Locale.ROOT, "%.1f %%", value);
    }
}