StandardParagraph.java

package pro.verron.officestamper.core;

import jakarta.xml.bind.JAXBElement;
import org.docx4j.wml.*;
import pro.verron.officestamper.api.*;
import pro.verron.officestamper.utils.WmlFactory;
import pro.verron.officestamper.utils.WmlUtils;

import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;

import static java.util.Arrays.stream;
import static java.util.stream.Collectors.joining;
import static pro.verron.officestamper.api.OfficeStamperException.throwing;
import static pro.verron.officestamper.utils.WmlUtils.getFirstParentWithClass;

/**
 * Represents a wrapper for managing and manipulating DOCX paragraph elements.
 * This class provides methods to manipulate the underlying paragraph content,
 * process placeholders, and interact with runs within the paragraph.
 */
public class StandardParagraph
        implements Paragraph {

    private static final Random RANDOM = new Random();
    private final DocxPart source;
    private final List<Object> contents;
    private final P p;
    private List<StandardRun> runs;

    /**
     * Constructs a new instance of the StandardParagraph class.
     *
     * @param source           the source DocxPart that contains the paragraph content.
     * @param paragraphContent the list of objects representing the paragraph content.
     * @param p                the P object representing the paragraph's structure.
     */
    private StandardParagraph(DocxPart source, List<Object> paragraphContent, P p) {
        this.source = source;
        this.contents = paragraphContent;
        this.p = p;
        this.runs = initializeRunList(contents);
    }

    /**
     * Initializes a list of StandardRun objects based on the given list of objects.
     * Iterates over the provided list of objects, identifies instances of type R,
     * and constructs StandardRun objects while keeping track of their lengths.
     *
     * @param objects the list of objects to be iterated over and processed into StandardRun instances
     *
     * @return a list of StandardRun objects created from the given input list
     */
    private static List<StandardRun> initializeRunList(List<Object> objects) {
        var currentLength = 0;
        var runList = new ArrayList<StandardRun>(objects.size());
        for (int i = 0; i < objects.size(); i++) {
            var object = objects.get(i);
            if (object instanceof R run) {
                var currentRun = new StandardRun(currentLength, i, run);
                runList.add(currentRun);
                currentLength += currentRun.length();
            }
        }
        return runList;
    }

    /**
     * Creates a new instance of StandardParagraph using the provided DocxPart and P objects.
     *
     * @param source    the source DocxPart containing the paragraph.
     * @param paragraph the P object representing the structure and content of the paragraph.
     *
     * @return a new instance of StandardParagraph constructed based on the provided source and paragraph.
     */
    public static StandardParagraph from(DocxPart source, P paragraph) {
        return new StandardParagraph(source, paragraph.getContent(), paragraph);
    }

    /**
     * Creates a new instance of StandardParagraph from the provided DocxPart and CTSdtContentRun objects.
     *
     * @param source    the source DocxPart containing the paragraph content.
     * @param paragraph the CTSdtContentRun object representing the content of the paragraph.
     *
     * @return a new instance of StandardParagraph constructed based on the provided DocxPart and paragraph.
     */
    public static StandardParagraph from(DocxPart source, CTSdtContentRun paragraph) {
        var parent = (SdtRun) paragraph.getParent();
        var parentParent = (P) parent.getParent();
        return new StandardParagraph(source, paragraph.getContent(), parentParent);
    }

    @Override
    public ProcessorContext processorContext(Placeholder placeholder) {
        var comment = comment(placeholder);
        var firstRun = (R) contents.getFirst();
        return new ProcessorContext(this, firstRun, comment, placeholder);
    }

    @Override
    public void replace(List<P> toRemove, List<P> toAdd) {
        var siblings = siblings();
        int index = siblings.indexOf(p);
        if (index < 0) throw new OfficeStamperException("Impossible");
        siblings.addAll(index, toAdd);
        siblings.removeAll(toRemove);
    }

    private List<Object> siblings() {
        return this.parent(ContentAccessor.class, 1)
                   .orElseThrow(throwing("This paragraph direct parent is not a classic parent object"))
                   .getContent();
    }

    private <T> Optional<T> parent(Class<T> aClass, int depth) {
        return getFirstParentWithClass(p, aClass, depth);
    }

    @Override
    public void remove() {
        WmlUtils.remove(p);
    }

    @Deprecated(since = "2.6", forRemoval = true)
    @Override
    public P getP() {
        return p;
    }

    /**
     * Replaces the given expression with the replacement object within the paragraph.
     * The replacement object must be a valid DOCX4J Object.
     *
     * @param placeholder the expression to be replaced.
     * @param replacement the object to replace the expression.
     */
    @Override
    public void replace(Placeholder placeholder, Object replacement) {
        assert WmlUtils.serializable(replacement);
        switch (replacement) {
            case R run -> replaceWithRun(placeholder, run);
            case Br br -> replaceWithBr(placeholder, br);
            default -> throw new AssertionError("Replacement must be a R or Br, but was a " + replacement.getClass());
        }
    }

    private void replaceWithRun(Placeholder placeholder, R replacement) {
        var text = asString();
        String full = placeholder.expression();

        int matchStartIndex = text.indexOf(full);
        if (matchStartIndex == -1) {
            // nothing to replace
            return;
        }
        int matchEndIndex = matchStartIndex + full.length();
        List<StandardRun> affectedRuns = getAffectedRuns(matchStartIndex, matchEndIndex);

        boolean singleRun = affectedRuns.size() == 1;

        if (singleRun) {
            StandardRun run = affectedRuns.getFirst();

            boolean expressionSpansCompleteRun = full.length() == run.length();
            boolean expressionAtStartOfRun = matchStartIndex == run.startIndex();
            boolean expressionAtEndOfRun = matchEndIndex == run.endIndex();
            boolean expressionWithinRun = matchStartIndex > run.startIndex() && matchEndIndex <= run.endIndex();

            replacement.setRPr(run.getPr());

            if (expressionSpansCompleteRun) {
                contents.set(run.indexInParent(), replacement);
            }
            else if (expressionAtStartOfRun) {
                run.replace(matchStartIndex, matchEndIndex, "");
                contents.add(run.indexInParent(), replacement);
            }
            else if (expressionAtEndOfRun) {
                run.replace(matchStartIndex, matchEndIndex, "");
                contents.add(run.indexInParent() + 1, replacement);
            }
            else if (expressionWithinRun) {
                int startIndex = run.indexOf(full);
                int endIndex = startIndex + full.length();
                var newStartRun = RunUtil.create(run.substring(0, startIndex),
                        run.run()
                           .getRPr());
                var newEndRun = RunUtil.create(run.substring(endIndex),
                        run.run()
                           .getRPr());
                contents.remove(run.indexInParent());
                contents.addAll(run.indexInParent(), List.of(newStartRun, replacement, newEndRun));
            }
        }
        else {
            StandardRun firstRun = affectedRuns.getFirst();
            StandardRun lastRun = affectedRuns.getLast();
            replacement.setRPr(firstRun.getPr());
            removeExpression(firstRun, matchStartIndex, matchEndIndex, lastRun, affectedRuns);
            // add replacement run between first and last run
            contents.add(firstRun.indexInParent() + 1, replacement);
        }
        this.runs = initializeRunList(contents);
    }

    private void replaceWithBr(Placeholder placeholder, Br br) {
        for (StandardRun standardRun : runs) {
            var runContentIterator = standardRun.run()
                                                .getContent()
                                                .listIterator();
            while (runContentIterator.hasNext()) {
                Object element = runContentIterator.next();
                if (element instanceof JAXBElement<?> jaxbElement && !jaxbElement.getName()
                                                                                 .getLocalPart()
                                                                                 .equals("instrText"))
                    element = jaxbElement.getValue();
                if (element instanceof Text text) replaceWithBr(placeholder, br, text, runContentIterator);
            }
        }
    }

    private List<StandardRun> getAffectedRuns(int startIndex, int endIndex) {
        return runs.stream()
                   .filter(run -> run.isTouchedByRange(startIndex, endIndex))
                   .toList();
    }

    private void removeExpression(
            StandardRun firstRun,
            int matchStartIndex,
            int matchEndIndex,
            StandardRun lastRun,
            List<StandardRun> affectedRuns
    ) {
        // remove the expression from the first run
        firstRun.replace(matchStartIndex, matchEndIndex, "");
        // remove all runs between first and last
        for (StandardRun run : affectedRuns) {
            if (!Objects.equals(run, firstRun) && !Objects.equals(run, lastRun)) {
                contents.remove(run.run());
            }
        }
        // remove the expression from the last run
        lastRun.replace(matchStartIndex, matchEndIndex, "");
    }

    private static void replaceWithBr(
            Placeholder placeholder,
            Br br,
            Text text,
            ListIterator<Object> runContentIterator
    ) {
        var value = text.getValue();
        runContentIterator.remove();
        var runLinebreakIterator = stream(value.split(placeholder.expression())).iterator();
        while (runLinebreakIterator.hasNext()) {
            var subText = WmlFactory.newText(runLinebreakIterator.next());
            runContentIterator.add(subText);
            if (runLinebreakIterator.hasNext()) runContentIterator.add(br);
        }
    }

    /**
     * Returns the aggregated text over all runs.
     *
     * @return the text of all runs.
     */
    @Override
    public String asString() {
        return runs.stream()
                   .map(StandardRun::getText)
                   .collect(joining());
    }

    @Override
    public void apply(Consumer<P> pConsumer) {
        pConsumer.accept(p);
    }

    @Override
    public <T> Optional<T> parent(Class<T> aClass) {
        return parent(aClass, Integer.MAX_VALUE);
    }

    @Override
    public Collection<Comments.Comment> getComment() {
        return CommentUtil.getCommentFor(contents, source.document());
    }

    private Comment comment(Placeholder placeholder) {
        var id = new BigInteger(16, RANDOM);
        return StandardComment.create(source.document(), p, placeholder, id);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return asString();
    }

    /**
     * Represents a run (i.e., a text fragment) in a paragraph. The run is indexed relative to the containing paragraph
     * and also relative to the containing document.
     *
     * @param startIndex    the start index of the run relative to the containing paragraph.
     * @param indexInParent the index of the run relative to the containing document.
     * @param run           the run itself.
     *
     * @author Joseph Verron
     * @author Tom Hombergs
     * @version ${version}
     * @since 1.0.0
     */
    public record StandardRun(int startIndex, int indexInParent, R run) {

        /**
         * Retrieves a substring from the text content of this run, starting at the specified begin index.
         *
         * @param beginIndex the beginning index, inclusive, for the substring.
         *
         * @return the substring of the run's text starting from the specified begin index to the end of the text.
         */
        public String substring(int beginIndex) {
            return getText().substring(beginIndex);
        }

        /**
         * Retrieves a substring from the text content of this run, starting
         * at the specified begin index and ending at the specified end index.
         *
         * @param beginIndex the beginning index, inclusive, for the substring.
         * @param endIndex   the ending index, exclusive, for the substring.
         *
         * @return the substring of the run's text from the specified begin index to the specified end index.
         */
        public String substring(int beginIndex, int endIndex) {
            return getText().substring(beginIndex, endIndex);
        }

        /**
         * Finds the index of the first occurrence of the specified substring in the text of the current run.
         *
         * @param full the substring to search for within the run's text.
         *
         * @return the index of the first occurrence of the specified substring,
         * or –1 if the substring is not found.
         */
        public int indexOf(String full) {
            return getText().indexOf(full);
        }

        /**
         * Returns the text string of a run.
         *
         * @return {@link String} representation of the run.
         */
        public String getText() {
            return RunUtil.getText(run);
        }

        /**
         * Retrieves the properties associated with this run.
         *
         * @return the {@link RPr} object representing the properties of the run.
         */
        public RPr getPr() {
            return run.getRPr();
        }

        /**
         * Determines whether the current run is affected by the specified range of global start and end indices.
         * A run is considered "touched" if any part of it overlaps with the given range.
         *
         * @param globalStartIndex the global start index of the range.
         * @param globalEndIndex   the global end index of the range.
         *
         * @return {@code true} if the current run is touched by the specified range; {@code false} otherwise.
         */
        public boolean isTouchedByRange(int globalStartIndex, int globalEndIndex) {
            var startsInRange = (globalStartIndex < startIndex) && (startIndex <= globalEndIndex);
            var endsInRange = (globalStartIndex < endIndex()) && (endIndex() <= globalEndIndex);
            var rangeFullyContainsRun = (startIndex <= globalStartIndex) && (globalEndIndex <= endIndex());
            return startsInRange || endsInRange || rangeFullyContainsRun;
        }

        /**
         * Calculates the end index of the current run based on its start index and length.
         *
         * @return the end index of the run.
         */
        public int endIndex() {
            return startIndex + length();
        }

        /**
         * Calculates the length of the text content of this run.
         *
         * @return the length of the text in the current run.
         */
        public int length() {
            return getText().length();
        }

        /**
         * Replaces the substring starting at the given index with the given replacement string.
         *
         * @param globalStartIndex the global index at which to
         *                         start the replacement.
         * @param globalEndIndex   the global index at which to end
         *                         the replacement.
         * @param replacement      the string to replace the substring at the specified global index.
         */
        public void replace(int globalStartIndex, int globalEndIndex, String replacement) {
            int localStartIndex = globalIndexToLocalIndex(globalStartIndex);
            int localEndIndex = globalIndexToLocalIndex(globalEndIndex);
            var text = substring(0, localStartIndex);
            text += replacement;
            String runText = getText();
            if (!runText.isEmpty()) {
                text += substring(localEndIndex);
            }
            RunUtil.setText(run, text);
        }

        /**
         * Converts a global index to a local index within the context of this run.
         * (meaning the index relative to multiple aggregated runs)
         *
         * @param globalIndex the global index to convert.
         *
         * @return the local index corresponding to the given global index.
         */
        private int globalIndexToLocalIndex(int globalIndex) {
            if (globalIndex < startIndex) return 0;
            else if (globalIndex > endIndex()) return length();
            else return globalIndex - startIndex;
        }


    }
}