/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 */
package groovy.util;

import groovy.lang.Closure;
import groovy.lang.DelegatingMetaClass;
import groovy.lang.GroovySystem;
import groovy.lang.MetaClass;
import groovy.lang.Tuple2;
import groovy.namespace.QName;
import org.codehaus.groovy.runtime.DefaultGroovyMethods;
import org.codehaus.groovy.runtime.InvokerHelper;
import org.codehaus.groovy.runtime.StringGroovyMethods;
import org.codehaus.groovy.util.ListHashMap;

import java.io.PrintWriter;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Stack;

/**
 * Represents an arbitrary tree node which can be used for structured metadata or any arbitrary XML-like tree.
 * A node can have a name, a value and an optional Map of attributes.
 * Typically the name is a String and a value is either a String or a List of other Nodes,
 * though the types are extensible to provide a flexible structure, e.g. you could use a
 * QName as the name which includes a namespace URI and a local name. Or a JMX ObjectName etc.
 * So this class can represent metadata like <code>{foo a=1 b="abc"}</code> or nested
 * metadata like <code>{foo a=1 b="123" { bar x=12 text="hello" }}</code>
 */
public class Node implements Serializable, Cloneable {

    static {
        // wrap the standard MetaClass with the delegate
        setMetaClass(GroovySystem.getMetaClassRegistry().getMetaClass(Node.class), Node.class);
    }

    @Serial private static final long serialVersionUID = 4121134753270542643L;

    private Node parent;

    private final Object name;

    private final Map attributes;

    private Object value;

    /**
     * Creates a new Node with the same name, no parent, shallow cloned attributes
     * and if the value is a NodeList, a (deep) clone of those nodes.
     *
     * @return the clone
     */
    @Override
    public Object clone() {
        Object newValue = value;
        if (value instanceof NodeList nodes) {
            newValue = nodes.clone();
        }
        return new Node(null, name, new HashMap(attributes), newValue);
    }

    /**
     * Creates a new Node named <code>name</code> and if a parent is supplied, adds
     * the newly created node as a child of the parent.
     *
     * @param parent the parent node or null if no parent
     * @param name   the name of the node
     */
    public Node(Node parent, Object name) {
        this(parent, name, new NodeList());
    }

    /**
     * Creates a new Node named <code>name</code> with value <code>value</code> and
     * if a parent is supplied, adds the newly created node as a child of the parent.
     *
     * @param parent the parent node or null if no parent
     * @param name   the name of the node
     * @param value  the Node value, e.g. some text but in general any Object
     */
    public Node(Node parent, Object name, Object value) {
        this(parent, name, new HashMap(), value);
    }

    /**
     * Creates a new Node named <code>name</code> with
     * attributes specified in the <code>attributes</code> Map. If a parent is supplied,
     * the newly created node is added as a child of the parent.
     *
     * @param parent     the parent node or null if no parent
     * @param name       the name of the node
     * @param attributes a Map of name-value pairs
     */
    public Node(Node parent, Object name, Map attributes) {
        this(parent, name, attributes, new NodeList());
    }

    /**
     * Creates a new Node named <code>name</code> with value <code>value</code> and
     * with attributes specified in the <code>attributes</code> Map. If a parent is supplied,
     * the newly created node is added as a child of the parent.
     *
     * @param parent     the parent node or null if no parent
     * @param name       the name of the node
     * @param attributes a Map of name-value pairs
     * @param value      the Node value, e.g. some text but in general any Object
     */
    public Node(Node parent, Object name, Map attributes, Object value) {
        this.parent = parent;
        this.name = name;
        this.attributes = attributes;
        this.value = value;

        if (parent != null) {
            getParentList(parent).add(this);
        }
    }

    private static List getParentList(Node parent) {
        Object parentValue = parent.value();
        List parentList;
        if (parentValue instanceof List) {
            parentList = (List) parentValue;
        } else {
            parentList = new NodeList();
            parentList.add(parentValue);
            parent.setValue(parentList);
        }
        return parentList;
    }

    /**
     * Appends a child to the current node.
     *
     * @param child the child to append
     * @return <code>true</code>
     */
    public boolean append(Node child) {
        child.setParent(this);
        return getParentList(this).add(child);
    }

    /**
     * Removes a child of the current node.
     *
     * @param child the child to remove
     * @return <code>true</code> if the param was a child of the current node
     */
    public boolean remove(Node child) {
        child.setParent(null);
        return getParentList(this).remove(child);
    }

    /**
     * Creates a new node as a child of the current node.
     *
     * @param name the name of the new node
     * @param attributes the attributes of the new node
     * @return the newly created <code>Node</code>
     */
    public Node appendNode(Object name, Map attributes) {
        return new Node(this, name, attributes);
    }

    /**
     * Creates a new node as a child of the current node.
     *
     * @param name the name of the new node
     * @return the newly created <code>Node</code>
     */
    public Node appendNode(Object name) {
        return new Node(this, name);
    }

    /**
     * Creates a new node as a child of the current node.
     *
     * @param name the name of the new node
     * @param value the value of the new node
     * @return the newly created <code>Node</code>
     */
    public Node appendNode(Object name, Object value) {
        return new Node(this, name, value);
    }

    /**
     * Creates a new node as a child of the current node.
     *
     * @param name the name of the new node
     * @param attributes the attributes of the new node
     * @param value the value of the new node
     * @return the newly created <code>Node</code>
     */
    public Node appendNode(Object name, Map attributes, Object value) {
        return new Node(this, name, attributes, value);
    }

    /**
     * Replaces the current node with nodes defined using builder-style notation via a Closure.
     *
     * @param c A Closure defining the new nodes using builder-style notation.
     * @return the original now replaced node
     */
    public Node replaceNode(Closure c) {
        if (parent() == null) {
            throw new UnsupportedOperationException("Replacing the root node is not supported");
        }
        appendNodes(c);
        getParentList(parent()).remove(this);
        this.setParent(null);
        return this;
    }

    /**
     * Replaces the current node with the supplied node.
     *
     * @param n the new Node
     * @return the original now replaced node
     */
    public Node replaceNode(Node n) {
        if (parent() == null) {
            throw new UnsupportedOperationException("Replacing the root node is not supported");
        }
        List tail = getTail();
        parent().appendNode(n.name(), n.attributes(), n.value());
        parent().children().addAll(tail);
        getParentList(parent()).remove(this);
        this.setParent(null);
        return this;
    }

    private List getTail() {
        List list = parent().children();
        int afterIndex = list.indexOf(this);
        List tail = new ArrayList(list.subList(afterIndex + 1, list.size()));
        list.subList(afterIndex + 1, list.size()).clear();
        return tail;
    }

    /**
     * Adds sibling nodes (defined using builder-style notation via a Closure) after the current node.
     *
     * @param c A Closure defining the new sibling nodes to add using builder-style notation.
     */
    public void plus(Closure c) {
        if (parent() == null) {
            throw new UnsupportedOperationException("Adding sibling nodes to the root node is not supported");
        }
        appendNodes(c);
    }

    private void appendNodes(Closure c) {
        List tail = getTail();
        for (Node child : buildChildrenFromClosure(c)) {
            parent().appendNode(child.name(), child.attributes(), child.value());
        }
        parent().children().addAll(tail);
    }

    private static List<Node> buildChildrenFromClosure(Closure c) {
        NodeBuilder b = new NodeBuilder();
        Node newNode = (Node) b.invokeMethod("dummyNode", c);
        return newNode.children();
    }

    /**
     * Extension point for subclasses to override the metaclass. The default
     * one supports the property and @ attribute notations.
     *
     * @param metaClass the original metaclass
     * @param nodeClass the class whose metaclass we wish to override (this class or a subclass)
     */
    protected static void setMetaClass(final MetaClass metaClass, final Class nodeClass) {
        // TODO Is protected static a bit of a smell?
        // TODO perhaps set nodeClass to be Class<? extends Node>
        GroovySystem.getMetaClassRegistry().setMetaClass(nodeClass, new NodeMetaClass(metaClass));
    }

    private static final class NodeMetaClass extends DelegatingMetaClass {
        private NodeMetaClass(final MetaClass metaClass) {
            super(metaClass);
        }

        /** {@inheritDoc} */
        @Override
        public Object getAttribute(final Object object, final String attribute) {
            return ((Node) object).get("@" + attribute);
        }

        /** {@inheritDoc} */
        @Override
        public Object getAttribute(final Class sender, final Object object, final String attribute, final boolean isSuper) {
            return getAttribute(object, attribute);
        }

        /** {@inheritDoc} */
        @Override
        public void setAttribute(final Object object, final String attribute, final Object newValue) {
            ((Node) object).attributes().put(attribute, newValue);
        }

        /** {@inheritDoc} */
        @Override
        public void setAttribute(final Class sender, final Object object, final String attribute, final Object newValue, final boolean isSuper, final boolean isInner) {
            setAttribute(object, attribute, newValue);
        }

        /** {@inheritDoc} */
        @Override
        public Object getProperty(final Object object, final String property) {
            if (object instanceof Node) {
                return ((Node) object).get(property);
            }
            return super.getProperty(object, property);
        }

        /** {@inheritDoc} */
        @Override
        public Object getProperty(final Class sender, final Object object, final String property, final boolean isSuper, final boolean isInner) {
            if (object instanceof Node) {
                return ((Node) object).get(property);
            }
            return super.getProperty(sender, object, property, isSuper, isInner);
        }

        /** {@inheritDoc} */
        @Override
        public void setProperty(final Object object, final String property, final Object newValue) {
            if (property.startsWith("@")) {
                setAttribute(object, property.substring(1), newValue);
            } else {
                super.setProperty(object, property, newValue);
            }
        }

        /** {@inheritDoc} */
        @Override
        public void setProperty(final Class sender, final Object object, final String property, final Object newValue, final boolean isSuper, final boolean isInner) {
            if (property.startsWith("@")) {
                setAttribute(object, property.substring(1), newValue);
            } else {
                super.setProperty(sender, object, property, newValue, isSuper, isInner);
            }
        }
    }

    /**
     * Returns the textual representation of the current node and all its child nodes.
     *
     * @return the text value of the node including child text
     */
    public String text() {
        if (value instanceof String) {
            return (String) value;
        }
        if (value instanceof NodeList) {
            return ((NodeList) value).text();
        }
        if (value instanceof Collection coll) {
            String previousText = null;
            StringBuilder sb = null;
            for (Object child : coll) {
                String childText = null;
                if (child instanceof String) {
                    childText = (String) child;
                } else if (child instanceof Node) {
                    childText = ((Node) child).text();
                }
                if (childText != null) {
                    if (previousText == null) {
                        previousText = childText;
                    } else {
                        if (sb == null) {
                            sb = new StringBuilder();
                            sb.append(previousText);
                        }
                        sb.append(childText);
                    }
                }
            }
            if (sb != null) {
                return sb.toString();
            } else {
                return Objects.requireNonNullElse(previousText, "");
            }
        }
        return "" + value;
    }

    /**
     * Returns an <code>Iterator</code> of the children of the node.
     *
     * @return the iterator of the nodes children
     */
    public Iterator iterator() {
        return children().iterator();
    }

    /**
     * Returns a <code>List</code> of the nodes children.
     *
     * @return the nodes children
     */
    public List children() {
        if (value == null) {
            return new NodeList();
        }
        if (value instanceof List) {
            return (List) value;
        }
        // we're probably just a String
        List result = new NodeList();
        result.add(value);
        return result;
    }

    /**
     * Returns a <code>Map</code> of the attributes of the node or an empty <code>Map</code>
     * if the node does not have any attributes.
     *
     * @return the attributes of the node
     */
    public Map attributes() {
        return attributes;
    }

    /**
     * Provides lookup of attributes by key.
     *
     * @param key the key of interest
     * @return the attribute matching the key or <code>null</code> if no match exists
     */
    public Object attribute(Object key) {
        return (attributes != null) ? attributes.get(key) : null;
    }

    /**
     * Returns an <code>Object</code> representing the name of the node.
     *
     * @return the name or <code>null</code> if name is empty
     */
    public Object name() {
        return name;
    }

    /**
     * Returns an <code>Object</code> representing the value of the node.
     *
     * @return the value or <code>null</code> if value is empty
     */
    public Object value() {
        return value;
    }

    /**
     * Adds or replaces the value of the node.
     *
     * @param value the new value of the node
     */
    public void setValue(Object value) {
        this.value = value;
    }

    /**
     * Returns the parent of the node.
     *
     * @return the parent or <code>null</code> for the root node
     */
    public Node parent() {
        return parent;
    }

    /**
     * Adds or replaces the parent of the node.
     *
     * @param parent the new parent of the node
     */
    protected void setParent(Node parent) {
        this.parent = parent;
    }

    /**
     * Provides lookup of elements by non-namespaced name
     *
     * @param key the name (or shortcut key) of the node(s) of interest
     * @return the nodes which match key
     */
    public Object get(String key) {
        if (key != null && key.charAt(0) == '@') {
            String attributeName = key.substring(1);
            return attributes().get(attributeName);
        }
        if ("..".equals(key)) {
            return parent();
        }
        if ("*".equals(key)) {
            return children();
        }
        if ("**".equals(key)) {
            return depthFirst();
        }
        return getByName(key);
    }

    /**
     * Provides lookup of elements by QName.
     *
     * @param name the QName of interest
     * @return the nodes matching name
     */
    public NodeList getAt(QName name) {
        NodeList answer = new NodeList();
        for (Object child : children()) {
            if (child instanceof Node childNode) {
                Object childNodeName = childNode.name();
                if (name.matches(childNodeName)) {
                    answer.add(childNode);
                }
            }
        }
        return answer;
    }

    /**
     * Provides lookup of elements by name.
     *
     * @param name the name of interest
     * @return the nodes matching name
     */
    private NodeList getByName(String name) {
        NodeList answer = new NodeList();
        for (Object child : children()) {
            if (child instanceof Node childNode) {
                Object childNodeName = childNode.name();
                if (childNodeName instanceof QName qn) {
                    if (qn.matches(name)) {
                        answer.add(childNode);
                    }
                } else if (name.equals(childNodeName)) {
                    answer.add(childNode);
                }
            }
        }
        return answer;
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a depth-first preorder traversal.
     *
     * @return the list of (depth-first) ordered nodes
     */
    public List depthFirst() {
        return depthFirst(true);
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a depth-first traversal.
     *
     * @param preorder if false, a postorder depth-first traversal will be performed
     * @return the list of (depth-first) ordered nodes
     * @since 2.5.0
     */
    public List depthFirst(boolean preorder) {
        List answer = new NodeList();
        if (preorder) answer.add(this);
        answer.addAll(depthFirstRest(preorder));
        if (!preorder) answer.add(this);
        return answer;
    }

    private List depthFirstRest(boolean preorder) {
        List answer = new NodeList();
        for (Iterator iter = InvokerHelper.asIterator(value); iter.hasNext(); ) {
            Object child = iter.next();
            if (child instanceof Node childNode) {
                List children = childNode.depthFirstRest(preorder);
                if (preorder) answer.add(childNode);
                if (children.size() > 1 || (children.size() == 1 && !(children.get(0) instanceof String))) answer.addAll(children);
                if (!preorder) answer.add(childNode);
            } else if (child instanceof String) {
                answer.add(child);
            }
        }
        return answer;
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a depth-first preorder traversal.
     *
     * @param c the closure to run for each node (a one or two parameter can be used; if one parameter is given the
     *          closure will be passed the node, for a two param closure the second parameter will be the level).
     * @since 2.5.0
     */
    public void depthFirst(Closure c) {
        Map<String, Object> options = new ListHashMap<String, Object>();
        options.put("preorder", true);
        depthFirst(options, c);
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a depth-first traversal.
     * A boolean 'preorder' options is supported.
     *
     * @param options map containing options
     * @param c the closure to run for each node (a one or two parameter can be used; if one parameter is given the
     *          closure will be passed the node, for a two param closure the second parameter will be the level).
     * @since 2.5.0
     */
    public void depthFirst(Map<String, Object> options, Closure c) {
        boolean preorder = Boolean.parseBoolean(options.get("preorder").toString());
        if (preorder) callClosureForNode(c, this, 1);
        depthFirstRest(preorder, 2, c);
        if (!preorder) callClosureForNode(c, this, 1);
    }

    private static <T> T callClosureForNode(Closure<T> closure, Object node, int level) {
        if (closure.getMaximumNumberOfParameters() == 2) {
            return closure.call(node, level);
        }
        return closure.call(node);
    }

    private void depthFirstRest(boolean preorder, int level, Closure c) {
        for (Iterator iter = InvokerHelper.asIterator(value); iter.hasNext(); ) {
            Object child = iter.next();
            if (child instanceof Node childNode) {
                if (preorder) callClosureForNode(c, childNode, level);
                childNode.depthFirstRest(preorder, level + 1, c);
                if (!preorder) callClosureForNode(c, childNode, level);
            }
        }
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a breadth-first preorder traversal.
     *
     * @return the list of (breadth-first) ordered nodes
     */
    public List breadthFirst() {
        return breadthFirst(true);
    }

    /**
     * Provides a collection of all the nodes in the tree
     * using a breadth-first traversal.
     *
     * @param preorder if false, a postorder breadth-first traversal will be performed
     * @return the list of (breadth-first) ordered nodes
     * @since 2.5.0
     */
    public List breadthFirst(boolean preorder) {
        List answer = new NodeList();
        if (preorder) answer.add(this);
        answer.addAll(breadthFirstRest(preorder));
        if (!preorder) answer.add(this);
        return answer;
    }

    private List breadthFirstRest(boolean preorder) {
        List answer = new NodeList();
        Stack stack = new Stack();
        List nextLevelChildren = preorder ? getDirectChildren() : DefaultGroovyMethods.reverse(getDirectChildren());
        while (!nextLevelChildren.isEmpty()) {
            List working = new NodeList(nextLevelChildren);
            nextLevelChildren = new NodeList();
            for (Object child : working) {
                if (preorder) {
                    answer.add(child);
                } else {
                    stack.push(child);
                }
                if (child instanceof Node childNode) {
                    List children = childNode.getDirectChildren();
                    if (children.size() > 1 || (children.size() == 1 && !(children.get(0) instanceof String))) nextLevelChildren.addAll(preorder ? children : DefaultGroovyMethods.reverse(children));
                }
            }
        }
        while (!stack.isEmpty()) {
            answer.add(stack.pop());
        }
        return answer;
    }

    /**
     * Calls the provided closure for all the nodes in the tree
     * using a breadth-first preorder traversal.
     *
     * @param c the closure to run for each node (a one or two parameter can be used; if one parameter is given the
     *          closure will be passed the node, for a two param closure the second parameter will be the level).
     * @since 2.5.0
     */
    public void breadthFirst(Closure c) {
        Map<String, Object> options = new ListHashMap<String, Object>();
        options.put("preorder", true);
        breadthFirst(options, c);
    }

    /**
     * Calls the provided closure for all the nodes in the tree
     * using a breadth-first traversal.
     * A boolean 'preorder' options is supported.
     *
     * @param options map containing options
     * @param c the closure to run for each node (a one or two parameter can be used; if one parameter is given the
     *          closure will be passed the node, for a two param closure the second parameter will be the level).
     * @since 2.5.0
     */
    public void breadthFirst(Map<String, Object> options, Closure c) {
        boolean preorder = Boolean.parseBoolean(options.get("preorder").toString());
        if (preorder) callClosureForNode(c, this, 1);
        breadthFirstRest(preorder, 2, c);
        if (!preorder) callClosureForNode(c, this, 1);
    }

    private void breadthFirstRest(boolean preorder, int level, Closure c) {
        Stack<Tuple2<Object, Integer>> stack = new Stack<Tuple2<Object, Integer>>();
        List nextLevelChildren = preorder ? getDirectChildren() : DefaultGroovyMethods.reverse(getDirectChildren());
        while (!nextLevelChildren.isEmpty()) {
            List working = new NodeList(nextLevelChildren);
            nextLevelChildren = new NodeList();
            for (Object child : working) {
                if (preorder) {
                    callClosureForNode(c, child, level);
                } else {
                    stack.push(new Tuple2<Object, Integer>(child, level));
                }
                if (child instanceof Node childNode) {
                    List children = childNode.getDirectChildren();
                    if (children.size() > 1 || (children.size() == 1 && !(children.get(0) instanceof String))) nextLevelChildren.addAll(preorder ? children : DefaultGroovyMethods.reverse(children));
                }
            }
            level++;
        }
        while (!stack.isEmpty()) {
            Tuple2<Object, Integer> next = stack.pop();
            callClosureForNode(c, next.getV1(), next.getV2());
        }
    }

    /**
     * Returns the list of any direct String nodes of this node.
     *
     * @return the list of String values from this node
     * @since 2.3.0
     */
    public List<String> localText() {
        List<String> answer = new ArrayList<String>();
        for (Iterator iter = InvokerHelper.asIterator(value); iter.hasNext(); ) {
            Object child = iter.next();
            if (!(child instanceof Node)) {
                answer.add(child.toString());
            }
        }
        return answer;
    }

    private List getDirectChildren() {
        List answer = new NodeList();
        for (Iterator iter = InvokerHelper.asIterator(value); iter.hasNext(); ) {
            Object child = iter.next();
            if (child instanceof Node childNode) {
                answer.add(childNode);
            } else if (child instanceof String) {
                answer.add(child);
            }
        }
        return answer;
    }

    /** {@inheritDoc} */
    @Override
    public String toString() {
        return name + "[attributes=" + attributes + "; value=" + value + "]";
    }

    /**
     * Writes the node to the specified <code>PrintWriter</code>.
     *
     * @param out the writer receiving the output
     */
    public void print(PrintWriter out) {
        new NodePrinter(out).print(this);
    }

    /**
     * Converts the text of this GPathResult to an Integer object.
     *
     * @return the GPathResult, converted to a <code>Integer</code>
     */
    public Integer toInteger() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toInteger((CharSequence)text());
    }

    /**
     * Converts the text of this GPathResult to a Long object.
     *
     * @return the GPathResult, converted to a <code>Long</code>
     */
    public Long toLong() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toLong((CharSequence)text());
    }

    /**
     * Converts the text of this GPathResult to a Float object.
     *
     * @return the GPathResult, converted to a <code>Float</code>
     */
    public Float toFloat() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toFloat((CharSequence)text());
    }

    /**
     * Converts the text of this GPathResult to a Double object.
     *
     * @return the GPathResult, converted to a <code>Double</code>
     */
    public Double toDouble() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toDouble((CharSequence)text());
    }

    /**
     * Converts the text of this GPathResult to a BigDecimal object.
     *
     * @return the GPathResult, converted to a <code>BigDecimal</code>
     */
    public BigDecimal toBigDecimal() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toBigDecimal((CharSequence)text());
    }

    /**
     * Converts the text of this GPathResult to a BigInteger object.
     *
     * @return the GPathResult, converted to a <code>BigInteger</code>
     */
    public BigInteger toBigInteger() {
        if(textIsEmptyOrNull()){
            return null;
        }
        return StringGroovyMethods.toBigInteger((CharSequence)text());
    }

    /**
     * The key used for text content when a node has both attributes and text.
     *
     * @since 6.0.0
     */
    public static final String TEXT_KEY = "_text";

    /**
     * Converts this Node tree into a nested {@code Map<String, Object>}.
     * <p>
     * Attributes and child elements are merged into a single map keyed by name.
     * If a child element name collides with an attribute name, the child element wins.
     * Leaf child elements (text only, no attributes) become String values.
     * Non-leaf child elements and leaf elements with attributes become nested Maps (recursive).
     * Repeated same-name sibling elements become Lists.
     * Elements with both attributes and text content use the {@value #TEXT_KEY} key for the text.
     *
     * @return a Map representation of this Node
     * @since 6.0.0
     */
    @SuppressWarnings("unchecked")
    public Map<String, Object> toMap() {
        Map<String, Object> map = new LinkedHashMap<>();

        // copy attributes, tracking which keys came from attributes
        java.util.Set<String> attributeKeys = new java.util.HashSet<>();
        for (Map.Entry<?, ?> entry : ((Map<?, ?>) attributes()).entrySet()) {
            String key = nameAsString(entry.getKey());
            map.put(key, entry.getValue());
            attributeKeys.add(key);
        }

        // process child nodes
        boolean hasChildElements = false;
        for (Object child : children()) {
            if (child instanceof Node childNode) {
                hasChildElements = true;
                String key = nameAsString(childNode.name());
                Object childValue;
                if (childNode.isLeaf() && childNode.attributes().isEmpty()) {
                    childValue = childNode.text();
                } else {
                    childValue = childNode.toMap();
                }
                Object existing = map.get(key);
                if (existing == null || attributeKeys.remove(key)) {
                    // first child element with this name (or replacing an attribute)
                    map.put(key, childValue);
                } else if (existing instanceof List) {
                    ((List<Object>) existing).add(childValue);
                } else {
                    List<Object> list = new ArrayList<>();
                    list.add(existing);
                    list.add(childValue);
                    map.put(key, list);
                }
            }
        }

        // if this node has attributes AND direct text, add _text entry
        if (!attributes().isEmpty() && !hasChildElements) {
            String text = text();
            if (!text.isEmpty()) {
                map.put(TEXT_KEY, text);
            }
        }

        return map;
    }

    private static String nameAsString(Object name) {
        if (name instanceof QName qn) {
            return qn.getLocalPart();
        }
        return name.toString();
    }

    private boolean isLeaf() {
        for (Object child : children()) {
            if (child instanceof Node) return false;
        }
        return true;
    }

    private boolean textIsEmptyOrNull() {
        String t = text();
        return null == t || t.isEmpty();
    }
}
