DecisionRationaleTemplateContract.java

package com.taxonomy.templates;

import org.apache.poi.xwpf.usermodel.BodyElementType;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Contract for the editable decision-rationale report template.
 *
 * <p>The cover page remains ordinary Word content. Taxonomy replaces stable text tokens,
 * removes the body marker, and appends the generated executive summary, decision chapters,
 * diagrams, and appendix. Keeping the marker as the final non-empty body block makes the
 * operation deterministic while preserving arbitrary branding, images, headers, footers,
 * styles, and page settings in the supported Word stories.</p>
 */
@Component
public final class DecisionRationaleTemplateContract implements DocumentTemplateContract {

    public static final String TEMPLATE_ID = "decision-rationale-report";
    public static final String DISPLAY_NAME = "Taxonomy decision rationale report";
    public static final String DEFAULT_RESOURCE =
            "document-templates/decision-rationale-report.dotx";

    public static final String BODY_MARKER = "{{taxonomy.report.body}}";
    public static final String TITLE_TOKEN = "{{taxonomy.report.title}}";
    public static final String REQUIREMENT_TOKEN = "{{taxonomy.report.requirement}}";

    private static final String WORD_NS =
            "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final String TOKEN_PREFIX = "{{taxonomy.";
    private static final Pattern TOKEN_PATTERN = Pattern.compile(
            "\\{\\{taxonomy\\.[A-Za-z0-9.]+}}");
    private static final Set<String> SUPPORTED_TOKENS = Set.of(
            BODY_MARKER,
            TITLE_TOKEN,
            REQUIREMENT_TOKEN,
            "{{taxonomy.report.subtitle}}",
            "{{taxonomy.report.status}}",
            "{{taxonomy.report.generatedAt}}",
            "{{taxonomy.report.generatedBy}}",
            "{{taxonomy.report.taxonomyVersion}}",
            "{{taxonomy.report.applicationVersion}}",
            "{{taxonomy.report.commit}}",
            "{{taxonomy.report.repository}}",
            "{{taxonomy.report.workspace}}",
            "{{taxonomy.report.branch}}",
            "{{taxonomy.report.basedOnCommit}}",
            "{{taxonomy.report.analysisProvider}}",
            "{{taxonomy.template.id}}",
            "{{taxonomy.template.commit}}",
            "{{taxonomy.template.sha256}}");
    private static final Set<String> UNSUPPORTED_TOKEN_CONTAINERS = Set.of(
            "txbxContent",
            "sdt",
            "altChunk",
            "customXml");

    @Override
    public String templateId() {
        return TEMPLATE_ID;
    }

    @Override
    public void validate(Map<String, byte[]> packageParts) {
        if (packageParts == null) {
            throw invalid("OOXML package is missing");
        }
        byte[] documentXml = packageParts.get("word/document.xml");
        if (documentXml == null) {
            throw invalid("word/document.xml is missing");
        }
        Document document = parse("word/document.xml", documentXml);
        NodeList bodies = document.getElementsByTagNameNS(WORD_NS, "body");
        if (bodies.getLength() != 1) {
            throw invalid("word/document.xml must contain exactly one body");
        }
        validateBody((Element) bodies.item(0));
        validateTokenPlacement(packageParts);
    }

    /**
     * Validate the materialized Word document immediately before report rendering.
     *
     * <p>This second check also protects deployments where an expert modified the JGit
     * repository directly and bypassed the web upload path.</p>
     */
    public void validateDocument(XWPFDocument document) {
        if (document == null) {
            throw invalid("Word package could not be opened");
        }
        boolean title = false;
        boolean requirement = false;
        int markerCount = 0;
        boolean markerSeen = false;

        List<IBodyElement> elements = document.getBodyElements();
        for (IBodyElement element : elements) {
            String text = bodyElementText(element);
            if (!markerSeen) {
                title |= text.contains(TITLE_TOKEN);
                requirement |= text.contains(REQUIREMENT_TOKEN);
            }
            boolean marker = element.getElementType() == BodyElementType.PARAGRAPH
                    && text.strip().equals(BODY_MARKER);
            if (marker) {
                markerCount++;
                markerSeen = true;
                continue;
            }
            if (markerSeen && !isPermittedEmptyTrailingParagraph(element, text)) {
                throw invalid("the body marker must be the final non-empty body block");
            }
        }
        requireTokens(title, requirement, markerCount);
    }

    private static void validateTokenPlacement(Map<String, byte[]> packageParts) {
        for (Map.Entry<String, byte[]> entry : packageParts.entrySet()) {
            String path = entry.getKey();
            String lower = path.toLowerCase(Locale.ROOT);
            if (!(lower.endsWith(".xml") || lower.endsWith(".rels"))) {
                continue;
            }

            Document document = parse(path, entry.getValue());
            rejectMarkersInAttributesOrXmlMetadata(path, document);
            Element root = document.getDocumentElement();
            String completeText = root == null ? "" : root.getTextContent();
            if (completeText == null || !completeText.contains(TOKEN_PREFIX)) {
                continue;
            }
            int expectedTokens = countTokens(path, completeText);
            if (!lower.startsWith("word/") || !lower.endsWith(".xml")) {
                throw invalid("Taxonomy tokens are not supported in OOXML part " + path);
            }

            NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p");
            int foundTokens = 0;
            for (int index = 0; index < paragraphs.getLength(); index++) {
                Element paragraph = (Element) paragraphs.item(index);
                String text = elementText(paragraph);
                if (!text.contains(TOKEN_PREFIX)) {
                    continue;
                }
                countTokens(path, text);
                Matcher matcher = TOKEN_PATTERN.matcher(text);
                while (matcher.find()) {
                    foundTokens++;
                    validateToken(path, paragraph, matcher.group());
                }
            }
            if (foundTokens != expectedTokens) {
                throw invalid("Taxonomy token is outside supported Word paragraph text in "
                        + path);
            }
        }
    }

    private static int countTokens(String path, String text) {
        Matcher matcher = TOKEN_PATTERN.matcher(text);
        int count = 0;
        while (matcher.find()) {
            count++;
        }
        String residual = TOKEN_PATTERN.matcher(text).replaceAll("");
        if (residual.contains(TOKEN_PREFIX)) {
            throw invalid("malformed Taxonomy token in " + path);
        }
        return count;
    }

    private static void rejectMarkersInAttributesOrXmlMetadata(
            String path,
            Node node) {
        if (node == null) {
            return;
        }
        var attributes = node.getAttributes();
        if (attributes != null) {
            for (int index = 0; index < attributes.getLength(); index++) {
                String value = attributes.item(index).getNodeValue();
                if (value != null && value.contains(TOKEN_PREFIX)) {
                    throw invalid("Taxonomy token is not permitted in an XML attribute in "
                            + path);
                }
            }
        }
        int nodeType = node.getNodeType();
        if (nodeType == Node.COMMENT_NODE
                || nodeType == Node.PROCESSING_INSTRUCTION_NODE) {
            String value = node.getNodeValue();
            if (value != null && value.contains(TOKEN_PREFIX)) {
                throw invalid("Taxonomy token is not permitted in XML metadata in " + path);
            }
        }
        NodeList children = node.getChildNodes();
        for (int index = 0; index < children.getLength(); index++) {
            rejectMarkersInAttributesOrXmlMetadata(path, children.item(index));
        }
    }

    private static void validateToken(
            String path,
            Element paragraph,
            String token) {
        if (!isSupportedStory(path)) {
            throw invalid("Taxonomy token " + token
                    + " is not supported in Word story " + path);
        }
        if (!SUPPORTED_TOKENS.contains(token)) {
            throw invalid("unknown Taxonomy template token " + token + " in " + path);
        }
        if (containsUnsupportedContainer(paragraph)) {
            throw invalid("Taxonomy token " + token
                    + " is inside an unsupported Word container in " + path);
        }
        if (BODY_MARKER.equals(token) && !"word/document.xml".equals(path)) {
            throw invalid("the body marker is permitted only in word/document.xml");
        }
    }

    private static boolean isSupportedStory(String path) {
        String lower = path.toLowerCase(Locale.ROOT);
        return "word/document.xml".equals(lower)
                || lower.matches("word/header[0-9]+\\.xml")
                || lower.matches("word/footer[0-9]+\\.xml");
    }

    private static boolean containsUnsupportedContainer(Element paragraph) {
        for (String localName : UNSUPPORTED_TOKEN_CONTAINERS) {
            if (paragraph.getElementsByTagNameNS(WORD_NS, localName).getLength() > 0) {
                return true;
            }
        }
        Node current = paragraph.getParentNode();
        while (current instanceof Element element) {
            if (UNSUPPORTED_TOKEN_CONTAINERS.contains(element.getLocalName())) {
                return true;
            }
            current = current.getParentNode();
        }
        return false;
    }

    private void validateBody(Element body) {
        boolean title = false;
        boolean requirement = false;
        int markerCount = 0;
        boolean markerSeen = false;

        NodeList children = body.getChildNodes();
        for (int index = 0; index < children.getLength(); index++) {
            Node node = children.item(index);
            if (!(node instanceof Element element)) {
                continue;
            }
            String localName = element.getLocalName();
            if ("sectPr".equals(localName)) {
                continue;
            }
            String text = elementText(element);
            if (!markerSeen) {
                title |= text.contains(TITLE_TOKEN);
                requirement |= text.contains(REQUIREMENT_TOKEN);
            }
            boolean marker = "p".equals(localName) && text.strip().equals(BODY_MARKER);
            if (marker) {
                markerCount++;
                markerSeen = true;
                continue;
            }
            if (markerSeen && !("p".equals(localName) && text.isBlank())) {
                throw invalid("the body marker must be the final non-empty body block");
            }
        }
        requireTokens(title, requirement, markerCount);
    }

    private static void requireTokens(
            boolean title,
            boolean requirement,
            int markerCount) {
        if (!title) {
            throw invalid("required token " + TITLE_TOKEN + " is missing");
        }
        if (!requirement) {
            throw invalid("required token " + REQUIREMENT_TOKEN + " is missing");
        }
        if (markerCount != 1) {
            throw invalid("required marker " + BODY_MARKER
                    + " must occur exactly once as a body paragraph");
        }
    }

    private static boolean isPermittedEmptyTrailingParagraph(
            IBodyElement element,
            String text) {
        return element.getElementType() == BodyElementType.PARAGRAPH && text.isBlank();
    }

    private static String bodyElementText(IBodyElement element) {
        if (element instanceof XWPFParagraph paragraph) {
            return paragraph.getText() == null ? "" : paragraph.getText();
        }
        if (element instanceof XWPFTable table) {
            return table.getText() == null ? "" : table.getText();
        }
        return "";
    }

    private static String elementText(Element element) {
        StringBuilder text = new StringBuilder();
        NodeList nodes = element.getElementsByTagNameNS(WORD_NS, "t");
        for (int index = 0; index < nodes.getLength(); index++) {
            text.append(nodes.item(index).getTextContent());
        }
        return text.toString();
    }

    private static Document parse(String path, byte[] content) {
        try {
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(true);
            factory.setXIncludeAware(false);
            factory.setExpandEntityReferences(false);
            factory.setFeature(
                    "http://apache.org/xml/features/disallow-doctype-decl", true);
            factory.setFeature(
                    "http://xml.org/sax/features/external-general-entities", false);
            factory.setFeature(
                    "http://xml.org/sax/features/external-parameter-entities", false);
            factory.setFeature(
                    "http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
            return factory.newDocumentBuilder()
                    .parse(new ByteArrayInputStream(content));
        } catch (ParserConfigurationException | SAXException | IOException exception) {
            throw invalid(path + " is invalid or unsafe", exception);
        }
    }

    private static IllegalArgumentException invalid(String detail) {
        return new IllegalArgumentException(
                "Decision report template '" + TEMPLATE_ID + "' is invalid: " + detail);
    }

    private static IllegalArgumentException invalid(String detail, Exception cause) {
        return new IllegalArgumentException(
                "Decision report template '" + TEMPLATE_ID + "' is invalid: " + detail,
                cause);
    }
}