/*
 * 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 org.apache.maven.internal.xml;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.XMLStreamWriter;

import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.maven.api.annotations.Nonnull;
import org.apache.maven.api.annotations.Nullable;
import org.apache.maven.api.xml.XmlNode;
import org.apache.maven.api.xml.XmlService;
import org.codehaus.stax2.util.StreamWriterDelegate;

public class DefaultXmlService extends XmlService {
    private static final boolean DEFAULT_TRIM = true;

    @Nonnull
    @Override
    public XmlNode doRead(InputStream input, @Nullable XmlService.InputLocationBuilder locationBuilder)
            throws XMLStreamException {
        XMLStreamReader parser = XmlService.newXMLInputFactory().createXMLStreamReader(input);
        return doRead(parser, locationBuilder);
    }

    @Nonnull
    @Override
    public XmlNode doRead(Reader reader, @Nullable XmlService.InputLocationBuilder locationBuilder)
            throws XMLStreamException {
        XMLStreamReader parser = XmlService.newXMLInputFactory().createXMLStreamReader(reader);
        return doRead(parser, locationBuilder);
    }

    @Nonnull
    @Override
    public XmlNode doRead(XMLStreamReader parser, @Nullable XmlService.InputLocationBuilder locationBuilder)
            throws XMLStreamException {
        return doBuild(parser, DEFAULT_TRIM, locationBuilder, new HashMap<>());
    }

    private XmlNode doBuild(
            XMLStreamReader parser,
            boolean trim,
            InputLocationBuilder locationBuilder,
            Map<String, String> parentNamespaces)
            throws XMLStreamException {
        boolean spacePreserve = false;
        String elementPrefix = null;
        String elementNamespaceUri = null;
        String elementName = null;
        String elementValue = null;
        Object location = null;
        Map<String, String> attrs = null;
        Map<String, String> nsContext = null;
        List<XmlNode> children = null;
        int eventType = parser.getEventType();
        int lastStartTag = -1;
        while (eventType != XMLStreamReader.END_DOCUMENT) {
            if (eventType == XMLStreamReader.START_ELEMENT) {
                lastStartTag = parser.getLocation().getLineNumber() * 1000
                        + parser.getLocation().getColumnNumber();
                // The first START_ELEMENT we encounter is "this" element;
                // subsequent START_ELEMENTs are children, handled in the else branch.
                if (elementName == null) {
                    int namespacesSize = parser.getNamespaceCount();
                    elementPrefix = parser.getPrefix();
                    elementNamespaceUri = parser.getNamespaceURI();
                    elementName = parser.getLocalName();
                    location = locationBuilder != null ? locationBuilder.toInputLocation(parser) : null;
                    // Build the namespace context: start with inherited, add local declarations.
                    // The default namespace (empty prefix) is excluded because per the XML namespace
                    // spec (Section 6.2), default namespace declarations do NOT apply to attributes.
                    nsContext = new HashMap<>(parentNamespaces);
                    int attributesSize = parser.getAttributeCount();
                    if (attributesSize > 0 || namespacesSize > 0) {
                        attrs = new HashMap<>();
                        for (int i = 0; i < namespacesSize; i++) {
                            String nsPrefix = parser.getNamespacePrefix(i);
                            String nsUri = parser.getNamespaceURI(i);
                            if (nsPrefix != null && !nsPrefix.isEmpty()) {
                                nsContext.put(nsPrefix, nsUri);
                                attrs.put("xmlns:" + nsPrefix, nsUri);
                            } else {
                                attrs.put("xmlns", nsUri);
                            }
                        }
                        for (int i = 0; i < attributesSize; i++) {
                            String attrName = parser.getAttributeLocalName(i);
                            String attrValue = parser.getAttributeValue(i);
                            String attrPrefix = parser.getAttributePrefix(i);
                            if (attrPrefix != null && !attrPrefix.isEmpty()) {
                                attrName = attrPrefix + ":" + attrName;
                            }
                            attrs.put(attrName, attrValue);
                            spacePreserve =
                                    spacePreserve || ("xml:space".equals(attrName) && "preserve".equals(attrValue));
                        }
                    }
                } else {
                    if (children == null) {
                        children = new ArrayList<>();
                    }
                    XmlNode child = doBuild(parser, trim, locationBuilder, nsContext);
                    children.add(child);
                }
            } else if (eventType == XMLStreamReader.CHARACTERS || eventType == XMLStreamReader.CDATA) {
                String text = parser.getText();
                elementValue = elementValue != null ? elementValue + text : text;
            } else if (eventType == XMLStreamReader.END_ELEMENT) {
                boolean emptyTag = lastStartTag
                        == parser.getLocation().getLineNumber() * 1000
                                + parser.getLocation().getColumnNumber();
                if (elementValue != null && trim && !spacePreserve) {
                    elementValue = elementValue.trim();
                }
                return XmlNode.newBuilder()
                        .prefix(elementPrefix)
                        .namespaceUri(elementNamespaceUri)
                        .name(elementName)
                        .value(children == null ? (elementValue != null ? elementValue : emptyTag ? null : "") : null)
                        .attributes(attrs)
                        .namespaces(nsContext)
                        .children(children)
                        .inputLocation(location)
                        .build();
            }
            eventType = parser.next();
        }
        throw new IllegalStateException("End of document found before returning to 0 depth");
    }

    @Override
    public void doWrite(XmlNode node, Writer writer) throws IOException {
        try {
            XMLOutputFactory factory = new com.ctc.wstx.stax.WstxOutputFactory();
            factory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, false);
            factory.setProperty(com.ctc.wstx.api.WstxOutputProperties.P_USE_DOUBLE_QUOTES_IN_XML_DECL, true);
            factory.setProperty(com.ctc.wstx.api.WstxOutputProperties.P_ADD_SPACE_AFTER_EMPTY_ELEM, true);
            XMLStreamWriter serializer = new IndentingXMLStreamWriter(factory.createXMLStreamWriter(writer));
            writeNode(serializer, node);
            serializer.close();
        } catch (XMLStreamException e) {
            throw new IOException(e);
        }
    }

    private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws XMLStreamException {
        xmlWriter.writeStartElement(node.prefix(), node.name(), node.namespaceUri());

        writeAttributes(xmlWriter, node.attributes(), node.namespaces());

        for (XmlNode child : node.children()) {
            writeNode(xmlWriter, child);
        }

        String value = node.value();
        if (value != null) {
            xmlWriter.writeCharacters(value);
        }

        xmlWriter.writeEndElement();
    }

    /**
     * Writes XmlNode attributes, properly handling namespace declarations
     * ({@code xmlns:prefix}) and prefixed attributes ({@code prefix:localName}).
     * The namespace context is used to resolve prefixes when the {@code xmlns:}
     * declaration is not present in the attribute map (e.g., it was declared on
     * an ancestor element).
     *
     * @param xmlWriter the StAX writer
     * @param attributes the attribute map (may contain xmlns: entries)
     * @param namespaces the namespace context (prefix → URI) for resolving prefixed attributes
     */
    private static void writeAttributes(
            XMLStreamWriter xmlWriter, Map<String, String> attributes, Map<String, String> namespaces)
            throws XMLStreamException {
        // Collect which namespace prefixes need to be declared on this element:
        // start with those explicitly in attributes (xmlns:prefix), then add
        // any prefixes used by attributes that are resolved from the namespace context
        Set<String> declaredPrefixes = new HashSet<>();
        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
            String key = attribute.getKey();
            if ("xmlns".equals(key)) {
                xmlWriter.writeDefaultNamespace(attribute.getValue());
            } else if (key.startsWith("xmlns:")) {
                String prefix = key.substring(6);
                xmlWriter.writeNamespace(prefix, attribute.getValue());
                declaredPrefixes.add(prefix);
            }
        }
        // Write prefixed attributes, declaring their namespace if needed
        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
            String key = attribute.getKey();
            String value = attribute.getValue();
            if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
                continue; // already written above
            } else if (key.startsWith("xml:")) {
                // The xml: prefix is predefined and bound to the XML namespace.
                // It must not be declared, but attributes like xml:space still need
                // to be written using the proper namespace URI.
                xmlWriter.writeAttribute("http://www.w3.org/XML/1998/namespace", key.substring(4), value);
            } else if (key.contains(":")) {
                int colon = key.indexOf(':');
                String prefix = key.substring(0, colon);
                String localName = key.substring(colon + 1);
                // Look up namespace URI: first from local xmlns: declarations, then from context
                String nsUri = attributes.get("xmlns:" + prefix);
                if (nsUri == null) {
                    nsUri = namespaces.get(prefix);
                }
                if (nsUri != null) {
                    // Declare the namespace if not already declared on this element
                    if (declaredPrefixes.add(prefix)) {
                        xmlWriter.writeNamespace(prefix, nsUri);
                    }
                    xmlWriter.writeAttribute(prefix, nsUri, localName, value);
                } else {
                    // No namespace declaration found for this prefix; write as unprefixed
                    // to produce valid XML
                    xmlWriter.writeAttribute(localName, value);
                }
            } else {
                xmlWriter.writeAttribute(key, value);
            }
        }
    }

    /**
     * Merges one DOM into another, given a specific algorithm and possible override points for that algorithm.<p>
     * The algorithm is as follows:
     * <ol>
     * <li> if the recessive DOM is null, there is nothing to do... return.</li>
     * <li> Determine whether the dominant node will suppress the recessive one (flag=mergeSelf).
     *   <ol type="A">
     *   <li> retrieve the 'combine.self' attribute on the dominant node, and try to match against 'override'...
     *        if it matches 'override', then set mergeSelf == false...the dominant node suppresses the recessive one
     *        completely.</li>
     *   <li> otherwise, use the default value for mergeSelf, which is true...this is the same as specifying
     *        'combine.self' == 'merge' as an attribute of the dominant root node.</li>
     *   </ol></li>
     * <li> If mergeSelf == true
     *   <ol type="A">
     *   <li> Determine whether children from the recessive DOM will be merged or appended to the dominant DOM as
     *        siblings (flag=mergeChildren).
     *     <ol type="i">
     *     <li> if childMergeOverride is set (non-null), use that value (true/false)</li>
     *     <li> retrieve the 'combine.children' attribute on the dominant node, and try to match against
     *          'append'...</li>
     *     <li> if it matches 'append', then set mergeChildren == false...the recessive children will be appended as
     *          siblings of the dominant children.</li>
     *     <li> otherwise, use the default value for mergeChildren, which is true...this is the same as specifying
     *         'combine.children' == 'merge' as an attribute on the dominant root node.</li>
     *     </ol></li>
     *   <li> Iterate through the recessive children, and:
     *     <ol type="i">
     *     <li> if mergeChildren == true and there is a corresponding dominant child (matched by element name),
     *          merge the two.</li>
     *     <li> otherwise, add the recessive child as a new child on the dominant root node.</li>
     *     </ol></li>
     *   </ol></li>
     * </ol>
     */
    @SuppressWarnings("checkstyle:MethodLength")
    public XmlNode doMerge(XmlNode dominant, XmlNode recessive, Boolean childMergeOverride) {
        // TODO: share this as some sort of assembler, implement a walk interface?
        if (recessive == null) {
            return dominant;
        }
        if (dominant == null) {
            return recessive;
        }

        boolean mergeSelf = true;

        String selfMergeMode = getSelfCombinationMode(dominant);

        if (SELF_COMBINATION_OVERRIDE.equals(selfMergeMode)) {
            mergeSelf = false;
        }

        if (mergeSelf) {

            String value = dominant.value();
            Object location = dominant.inputLocation();
            Map<String, String> attrs = dominant.attributes();
            List<XmlNode> children = null;

            for (Map.Entry<String, String> attr : recessive.attributes().entrySet()) {
                String key = attr.getKey();
                if (isEmpty(attrs.get(key))) {
                    if (attrs == dominant.attributes()) {
                        attrs = new HashMap<>(attrs);
                    }
                    attrs.put(key, attr.getValue());
                }
            }

            if (!recessive.children().isEmpty()) {
                boolean mergeChildren = true;
                if (childMergeOverride != null) {
                    mergeChildren = childMergeOverride;
                } else {
                    String childCombinationMode = getChildCombinationMode(attrs);
                    if (CHILDREN_COMBINATION_APPEND.equals(childCombinationMode)) {
                        mergeChildren = false;
                    }
                }

                Map<String, Iterator<XmlNode>> commonChildren = new HashMap<>();
                Set<String> names =
                        recessive.children().stream().map(XmlNode::name).collect(Collectors.toSet());
                for (String name : names) {
                    List<XmlNode> dominantChildren = dominant.children().stream()
                            .filter(n -> n.name().equals(name))
                            .toList();
                    if (!dominantChildren.isEmpty()) {
                        commonChildren.put(name, dominantChildren.iterator());
                    }
                }

                String keysValue = recessive.attribute(KEYS_COMBINATION_MODE_ATTRIBUTE);

                int recessiveChildIndex = 0;
                for (XmlNode recessiveChild : recessive.children()) {
                    String idValue = recessiveChild.attribute(ID_COMBINATION_MODE_ATTRIBUTE);

                    XmlNode childDom = null;
                    if (!isEmpty(idValue)) {
                        for (XmlNode dominantChild : dominant.children()) {
                            if (idValue.equals(dominantChild.attribute(ID_COMBINATION_MODE_ATTRIBUTE))) {
                                childDom = dominantChild;
                                // we have a match, so don't append but merge
                                mergeChildren = true;
                            }
                        }
                    } else if (!isEmpty(keysValue)) {
                        String[] keys = keysValue.split(",");
                        Map<String, Optional<String>> recessiveKeyValues = Stream.of(keys)
                                .collect(Collectors.toMap(
                                        k -> k, k -> Optional.ofNullable(recessiveChild.attribute(k))));

                        for (XmlNode dominantChild : dominant.children()) {
                            Map<String, Optional<String>> dominantKeyValues = Stream.of(keys)
                                    .collect(Collectors.toMap(
                                            k -> k, k -> Optional.ofNullable(dominantChild.attribute(k))));

                            if (recessiveKeyValues.equals(dominantKeyValues)) {
                                childDom = dominantChild;
                                // we have a match, so don't append but merge
                                mergeChildren = true;
                            }
                        }
                    } else {
                        childDom = dominant.child(recessiveChild.name());
                    }

                    if (mergeChildren && childDom != null) {
                        String name = recessiveChild.name();
                        Iterator<XmlNode> it =
                                commonChildren.computeIfAbsent(name, n1 -> Stream.of(dominant.children().stream()
                                                .filter(n2 -> n2.name().equals(n1))
                                                .collect(Collectors.toList()))
                                        .filter(l -> !l.isEmpty())
                                        .map(List::iterator)
                                        .findFirst()
                                        .orElse(null));
                        if (it == null) {
                            if (children == null) {
                                children = new ArrayList<>(dominant.children());
                            }
                            children.add(recessiveChild);
                        } else if (it.hasNext()) {
                            XmlNode dominantChild = it.next();

                            String dominantChildCombinationMode = getSelfCombinationMode(dominantChild);
                            if (SELF_COMBINATION_REMOVE.equals(dominantChildCombinationMode)) {
                                if (children == null) {
                                    children = new ArrayList<>(dominant.children());
                                }
                                children.remove(dominantChild);
                            } else {
                                int idx = dominant.children().indexOf(dominantChild);
                                XmlNode merged = merge(dominantChild, recessiveChild, childMergeOverride);
                                if (merged != dominantChild) {
                                    if (children == null) {
                                        children = new ArrayList<>(dominant.children());
                                    }
                                    children.set(idx, merged);
                                }
                            }
                        }
                    } else {
                        if (children == null) {
                            children = new ArrayList<>(dominant.children());
                        }
                        int idx = mergeChildren ? children.size() : recessiveChildIndex;
                        children.add(idx, recessiveChild);
                    }
                    recessiveChildIndex++;
                }
            }

            if (value != null || attrs != dominant.attributes() || children != null) {
                if (children == null) {
                    children = dominant.children();
                }
                if (!Objects.equals(value, dominant.value())
                        || !Objects.equals(attrs, dominant.attributes())
                        || !Objects.equals(children, dominant.children())
                        || !Objects.equals(location, dominant.inputLocation())) {
                    return XmlNode.newBuilder()
                            .prefix(dominant.prefix())
                            .namespaceUri(dominant.namespaceUri())
                            .name(dominant.name())
                            .value(value != null ? value : dominant.value())
                            .attributes(attrs)
                            .namespaces(dominant.namespaces())
                            .children(children)
                            .inputLocation(location)
                            .build();
                } else {
                    return dominant;
                }
            }
        }
        return dominant;
    }

    private static boolean isEmpty(String str) {
        return str == null || str.isEmpty();
    }

    private static String getSelfCombinationMode(XmlNode node) {
        String value = node.attribute(SELF_COMBINATION_MODE_ATTRIBUTE);
        return !isEmpty(value) ? value : DEFAULT_SELF_COMBINATION_MODE;
    }

    private static String getChildCombinationMode(Map<String, String> attributes) {
        String value = attributes.get(CHILDREN_COMBINATION_MODE_ATTRIBUTE);
        return !isEmpty(value) ? value : DEFAULT_CHILDREN_COMBINATION_MODE;
    }

    @Nullable
    private static XmlNode findNodeById(@Nonnull List<XmlNode> nodes, @Nonnull String id) {
        return nodes.stream()
                .filter(n -> id.equals(n.attribute(ID_COMBINATION_MODE_ATTRIBUTE)))
                .findFirst()
                .orElse(null);
    }

    @Nullable
    private static XmlNode findNodeByKeys(
            @Nonnull List<XmlNode> nodes, @Nonnull XmlNode target, @Nonnull String[] keys) {
        return nodes.stream()
                .filter(n -> matchesKeys(n, target, keys))
                .findFirst()
                .orElse(null);
    }

    private static boolean matchesKeys(@Nonnull XmlNode node1, @Nonnull XmlNode node2, @Nonnull String[] keys) {
        for (String key : keys) {
            String value1 = node1.attribute(key);
            String value2 = node2.attribute(key);
            if (!Objects.equals(value1, value2)) {
                return false;
            }
        }
        return true;
    }

    static class IndentingXMLStreamWriter extends StreamWriterDelegate {

        int depth = 0;
        boolean hasChildren = false;
        boolean anew = true;

        IndentingXMLStreamWriter(XMLStreamWriter parent) {
            super(parent);
        }

        @Override
        public void writeStartDocument() throws XMLStreamException {
            super.writeStartDocument();
            anew = false;
        }

        @Override
        public void writeStartDocument(String version) throws XMLStreamException {
            super.writeStartDocument(version);
            anew = false;
        }

        @Override
        public void writeStartDocument(String encoding, String version) throws XMLStreamException {
            super.writeStartDocument(encoding, version);
            anew = false;
        }

        @Override
        public void writeEmptyElement(String localName) throws XMLStreamException {
            indent();
            super.writeEmptyElement(localName);
            hasChildren = true;
            anew = false;
        }

        @Override
        public void writeEmptyElement(String namespaceURI, String localName) throws XMLStreamException {
            indent();
            super.writeEmptyElement(namespaceURI, localName);
            hasChildren = true;
            anew = false;
        }

        @Override
        public void writeEmptyElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
            indent();
            super.writeEmptyElement(prefix, localName, namespaceURI);
            hasChildren = true;
            anew = false;
        }

        @Override
        public void writeStartElement(String localName) throws XMLStreamException {
            indent();
            super.writeStartElement(localName);
            depth++;
            hasChildren = false;
            anew = false;
        }

        @Override
        public void writeStartElement(String namespaceURI, String localName) throws XMLStreamException {
            indent();
            super.writeStartElement(namespaceURI, localName);
            depth++;
            hasChildren = false;
            anew = false;
        }

        @Override
        public void writeStartElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
            indent();
            super.writeStartElement(prefix, localName, namespaceURI);
            depth++;
            hasChildren = false;
            anew = false;
        }

        @Override
        public void writeEndElement() throws XMLStreamException {
            depth--;
            if (hasChildren) {
                indent();
            }
            super.writeEndElement();
            hasChildren = true;
            anew = false;
        }

        private void indent() throws XMLStreamException {
            if (!anew) {
                super.writeCharacters("\n");
            }
            for (int i = 0; i < depth; i++) {
                super.writeCharacters("  ");
            }
        }
    }
}
