/*
 *  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
 *
 *      https://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.ivy.plugins.parser.xml;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;

import javax.xml.parsers.ParserConfigurationException;

import org.apache.ivy.core.module.descriptor.Configuration;
import org.apache.ivy.core.module.descriptor.DefaultModuleDescriptor;
import org.apache.ivy.core.module.descriptor.DependencyDescriptor;
import org.apache.ivy.core.module.descriptor.ExtendsDescriptor;
import org.apache.ivy.core.module.descriptor.InheritableItem;
import org.apache.ivy.core.module.descriptor.ModuleDescriptor;
import org.apache.ivy.core.module.id.ModuleId;
import org.apache.ivy.core.module.id.ModuleRevisionId;
import org.apache.ivy.plugins.namespace.NameSpaceHelper;
import org.apache.ivy.plugins.namespace.Namespace;
import org.apache.ivy.plugins.parser.ParserSettings;
import org.apache.ivy.plugins.repository.Resource;
import org.apache.ivy.plugins.repository.file.FileResource;
import org.apache.ivy.plugins.repository.url.URLResource;
import org.apache.ivy.util.Checks;
import org.apache.ivy.util.DateUtil;
import org.apache.ivy.util.Message;
import org.apache.ivy.util.XMLHelper;
import org.apache.ivy.util.extendable.ExtendableItemHelper;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.ext.LexicalHandler;
import org.xml.sax.helpers.DefaultHandler;

import static org.apache.ivy.util.StringUtils.isNullOrEmpty;
import static org.apache.ivy.util.StringUtils.joinArray;
import static org.apache.ivy.util.StringUtils.splitToArray;

/**
 * Used to update ivy files. Uses ivy file as source and not ModuleDescriptor to preserve as much as
 * possible the original syntax
 */
public final class XmlModuleDescriptorUpdater {
    // CheckStyle:StaticVariableName| OFF
    // LINE_SEPARATOR is actually a constant, but we have to modify it for the tests
    public static String LINE_SEPARATOR = System.lineSeparator();

    // CheckStyle:StaticVariableName| ON

    private XmlModuleDescriptorUpdater() {
    }

    /**
     * used to copy a module descriptor xml file (also known as ivy file) and update the revisions
     * of its dependencies, its status and revision
     *
     * @param srcURL
     *            the url of the source module descriptor file
     * @param destFile
     *            The file to which the updated module descriptor should be output
     * @param options
     *            UpdateOptions
     * @throws IOException if something goes wrong
     * @throws SAXException if something goes wrong
     */
    public static void update(URL srcURL, File destFile, UpdateOptions options) throws IOException,
            SAXException {
        if (destFile.getParentFile() != null) {
            destFile.getParentFile().mkdirs();
        }
        OutputStream destStream = new FileOutputStream(destFile);
        try {
            update(srcURL, destStream, options);
        } finally {
            try {
                destStream.close();
            } catch (IOException e) {
                Message.warn("failed to close a stream : " + e.toString());
            }
        }
    }

    public static void update(URL srcURL, OutputStream destFile, UpdateOptions options)
            throws IOException, SAXException {
        InputStream in = srcURL.openStream();
        try {
            update(srcURL, in, destFile, options);
        } finally {
            try {
                in.close();
            } catch (IOException e) {
                Message.warn("failed to close a stream : " + e.toString());
            }
            try {
                destFile.close();
            } catch (IOException e) {
                Message.warn("failed to close a stream : " + e.toString());
            }
        }

    }

    public static void update(InputStream in, Resource res, File destFile, UpdateOptions options)
            throws IOException, SAXException {
        if (destFile.getParentFile() != null) {
            destFile.getParentFile().mkdirs();
        }
        OutputStream fos = new FileOutputStream(destFile);
        try {
            // TODO: use resource as input stream context?
            URL inputStreamContext = null;
            if (res instanceof URLResource) {
                inputStreamContext = ((URLResource) res).getURL();
            } else if (res instanceof FileResource) {
                inputStreamContext = ((FileResource) res).getFile().toURI().toURL();
            }
            update(inputStreamContext, in, fos, options);
        } finally {
            try {
                in.close();
            } catch (IOException e) {
                Message.warn("failed to close a stream : " + e.toString());
            }
            try {
                fos.close();
            } catch (IOException e) {
                Message.warn("failed to close a stream : " + e.toString());
            }
        }
    }

    private static class UpdaterHandler extends DefaultHandler implements LexicalHandler {

        /** standard attributes of ivy-module/info */
        private static final Collection<String> STD_ATTS = Arrays.asList("organisation",
                "module", "branch", "revision", "status", "publication", "namespace");

        /** elements that may appear inside ivy-module, in expected order */
        private static final List<String> MODULE_ELEMENTS = Arrays.asList("info",
                "configurations", "publications", "dependencies", "conflicts");

        /** element position of "configurations" inside "ivy-module" */
        private static final int CONFIGURATIONS_POSITION = MODULE_ELEMENTS
                .indexOf("configurations");

        /** element position of "dependencies" inside "ivy-module" */
        private static final int DEPENDENCIES_POSITION = MODULE_ELEMENTS.indexOf("dependencies");

        /** elements that may appear inside of ivy-module/info */
        private static final Collection<String> INFO_ELEMENTS = Arrays.asList("extends",
                "ivyauthor", "license", "repository", "description");

        private final ParserSettings settings;

        private final PrintWriter out;

        private final Map<ModuleRevisionId, String> resolvedRevisions;

        private final Map<ModuleRevisionId, String> resolvedBranches;

        private final String status;

        private final String revision;

        private final Date pubdate;

        private final Namespace ns;

        private final boolean replaceInclude;

        private final boolean generateRevConstraint;

        private boolean inHeader = true;

        private final List<String> confs;

        private final URL relativePathCtx;

        private final UpdateOptions options;

        public UpdaterHandler(URL relativePathCtx, PrintWriter out, final UpdateOptions options) {
            this.options = options;
            this.settings = options.getSettings();
            this.out = out;
            this.resolvedRevisions = options.getResolvedRevisions();
            this.resolvedBranches = options.getResolvedBranches();
            this.status = options.getStatus();
            this.revision = options.getRevision();
            this.pubdate = options.getPubdate();
            this.ns = options.getNamespace();
            this.replaceInclude = options.isReplaceInclude();
            this.generateRevConstraint = options.isGenerateRevConstraint();
            this.relativePathCtx = relativePathCtx;
            if (options.getConfsToExclude() != null) {
                this.confs = Arrays.asList(options.getConfsToExclude());
            } else {
                this.confs = Collections.emptyList();
            }
        }

        // never print *ln* cause \n is found in copied characters stream
        // nor do we need do handle indentation, original one is maintained except for attributes

        private String organisation = null;

        // defaultConf of imported configurations, if any
        private String defaultConf = null;

        // defaultConfMapping of imported configurations, if any
        private String defaultConfMapping = null;

        // confMappingOverride of imported configurations, if any
        private Boolean confMappingOverride = null;

        // used to know if the last open tag was empty, to adjust termination
        // with /> instead of ></qName>
        private String justOpen = null;

        // track the size of the left indent, so that inserted elements are formatted
        // like nearby elements.

        // true when we're reading indent whitespace
        private boolean indenting;

        private StringBuilder currentIndent = new StringBuilder();

        private List<String> indentLevels = new ArrayList<>(); // ArrayList<String>

        // true if an ivy-module/info/description element has been found in the published descriptor
        private boolean hasDescription = false;

        // true if merged configurations have been written
        private boolean mergedConfigurations = false;

        // true if merged deps have been written
        private boolean mergedDependencies = false;

        // the new value of the defaultconf attribute on the publications tag
        private String newDefaultConf = null;

        private Stack<String> context = new Stack<>();

        private Stack<ExtendedBuffer> buffers = new Stack<>();

        private Stack<ExtendedBuffer> confAttributeBuffers = new Stack<>();

        public void startElement(String uri, String localName, String qName, Attributes attributes)
                throws SAXException {
            inHeader = false;
            endIndent();
            if (justOpen != null) {
                write(">");
            }

            flushMergedElementsBefore(qName);

            // according to ivy.xsd, all <dependency> elements must occur before
            // the <exclude>, <override> or <conflict> elements
            if (options.isMerge()
                    && ("exclude".equals(localName) || "override".equals(localName) || "conflict"
                            .equals(localName)) && "ivy-module/dependencies".equals(getContext())) {
                ModuleDescriptor merged = options.getMergedDescriptor();
                writeInheritedDependencies(merged);
                out.println();
                out.print(getIndent());
            }

            context.push(qName);

            String path = getContext();
            if ("info".equals(qName)) {
                infoStarted(attributes);
            } else if (replaceInclude && "include".equals(qName)
                    && context.contains("configurations")) {
                // TODO, in the case of !replaceInclude, we should still replace the relative path
                // by an absolute path.
                includeStarted(attributes);
            } else if ("ivy-module/info/extends".equals(path)) {
                if (options.isMerge()) {
                    ModuleDescriptor mergedDescriptor = options.getMergedDescriptor();
                    for (ExtendsDescriptor inheritedDescriptor : mergedDescriptor.getInheritedDescriptors()) {
                        ModuleDescriptor rprid = inheritedDescriptor.getParentMd();
                        if (rprid instanceof DefaultModuleDescriptor) {
                            DefaultModuleDescriptor defaultModuleDescriptor = (DefaultModuleDescriptor) rprid;
                            if (defaultModuleDescriptor.getDefaultConf() != null) {
                                defaultConf = defaultModuleDescriptor.getDefaultConf();
                            }
                            if (defaultModuleDescriptor.getDefaultConfMapping() != null) {
                                defaultConfMapping = defaultModuleDescriptor.getDefaultConfMapping();
                            }
                            if (defaultModuleDescriptor.isMappingOverride()) {
                                confMappingOverride = Boolean.TRUE;
                            }
                        }
                    }
                }
                startExtends(attributes);
            } else if ("ivy-module/dependencies/dependency".equals(path)) {
                startElementInDependency(attributes);
            } else if ("ivy-module/configurations/conf".equals(path)) {
                startElementInConfigurationsConf(qName, attributes);
            } else if ("dependencies".equals(qName) || "configurations".equals(qName)) {
                startElementWithConfAttributes(qName, attributes);
            } else if ("ivy-module/publications/artifact/conf".equals(path)
                    || "ivy-module/dependencies/dependency/conf".equals(path)
                    || "ivy-module/dependencies/dependency/artifact/conf".equals(path)) {
                buffers.push(new ExtendedBuffer(getContext()));
                confAttributeBuffers.peek().setDefaultPrint(false);
                String confName = substitute(settings, attributes.getValue("name"));
                if (!confs.contains(confName)) {
                    confAttributeBuffers.peek().setPrint(true);
                    buffers.peek().setPrint(true);
                    write("<" + qName);
                    for (int i = 0; i < attributes.getLength(); i++) {
                        write(" " + attributes.getQName(i) + "=\""
                                + substitute(settings, attributes.getValue(i)) + "\"");
                    }
                }
            } else if ("ivy-module/publications/artifact".equals(path)) {
                ExtendedBuffer buffer = new ExtendedBuffer(getContext());
                buffers.push(buffer);
                confAttributeBuffers.push(buffer);
                write("<" + qName);
                buffer.setDefaultPrint(attributes.getValue("conf") == null
                        && (newDefaultConf == null || !newDefaultConf.isEmpty()));
                for (int i = 0; i < attributes.getLength(); i++) {
                    String attName = attributes.getQName(i);
                    if ("conf".equals(attName)) {
                        String confName = substitute(settings, attributes.getValue("conf"));
                        String newConf = removeConfigurationsFromList(confName);
                        if (!newConf.isEmpty()) {
                            write(" " + attName + "=\"" + newConf + "\"");
                            buffers.peek().setPrint(true);
                        }
                    } else {
                        write(" " + attName + "=\""
                                + substitute(settings, attributes.getValue(i)) + "\"");
                    }
                }
            } else if ("ivy-module/dependencies/dependency/artifact".equals(path)) {
                ExtendedBuffer buffer = new ExtendedBuffer(getContext());
                buffers.push(buffer);
                confAttributeBuffers.push(buffer);
                write("<" + qName);
                buffer.setDefaultPrint(attributes.getValue("conf") == null);
                for (int i = 0; i < attributes.getLength(); i++) {
                    String attName = attributes.getQName(i);
                    if ("conf".equals(attName)) {
                        String confName = substitute(settings, attributes.getValue("conf"));
                        String newConf = removeConfigurationsFromList(confName);
                        if (!newConf.isEmpty()) {
                            write(" " + attName + "=\"" + newConf + "\"");
                            buffers.peek().setPrint(true);
                        }
                    } else {
                        write(" " + attName + "=\""
                                + substitute(settings, attributes.getValue(i)) + "\"");
                    }
                }
            } else if ("ivy-module/publications".equals(path)) {
                startPublications(attributes);
            } else {
                if (options.isMerge() && path.startsWith("ivy-module/info")) {
                    ModuleDescriptor merged = options.getMergedDescriptor();
                    if (path.equals("ivy-module/info/description")) {
                        // if the descriptor already contains a description, don't bother printing
                        // the merged version.
                        hasDescription = true;
                    } else if (!INFO_ELEMENTS.contains(qName)) {
                        // according to the XSD, we should write description after all of the other
                        // standard <info> elements but before any extended elements.
                        writeInheritedDescription(merged);
                    }
                }

                // copy
                write("<" + qName);
                for (int i = 0; i < attributes.getLength(); i++) {
                    write(" " + attributes.getQName(i) + "=\""
                            + substitute(settings, attributes.getValue(i)) + "\"");
                }
            }
            justOpen = qName;
            // indent.append("\t");
        }

        private void startExtends(Attributes attributes) {
            // in merge mode, comment out extends element
            if (options.isMerge()) {
                write("<!-- ");
            }
            write("<extends");

            String org = substitute(settings, attributes.getValue("organisation"));
            String module = substitute(settings, attributes.getValue("module"));
            ModuleId parentId = new ModuleId(org, module);

            for (int i = 0; i < attributes.getLength(); i++) {
                String name = attributes.getQName(i);
                String value = null;

                switch (name) {
                    case "organisation":
                        value = org;
                        break;
                    case "module":
                        value = module;
                        break;
                    case "revision":
                        // replace inline revision with resolved parent revision
                        ModuleDescriptor merged = options.getMergedDescriptor();
                        if (merged != null) {
                            for (ExtendsDescriptor parent : merged.getInheritedDescriptors()) {
                                ModuleRevisionId resolvedId = parent.getResolvedParentRevisionId();
                                if (parentId.equals(resolvedId.getModuleId())) {
                                    value = resolvedId.getRevision();
                                    if (value != null) {
                                        break;
                                    }
                                }
                            }
                        }
                        if (value == null) {
                            value = substitute(settings, attributes.getValue(i));
                        }
                        break;
                    default:
                        value = substitute(settings, attributes.getValue(i));
                        break;
                }
                write(" " + name + "=\"" + value + "\"");
            }
        }

        private void startElementInConfigurationsConf(String qName, Attributes attributes) {
            buffers.push(new ExtendedBuffer(getContext()));
            String confName = substitute(settings, attributes.getValue("name"));
            if (!confs.contains(confName)) {
                buffers.peek().setPrint(true);
                String extend = substitute(settings, attributes.getValue("extends"));
                if (extend != null) {
                    for (String tok : splitToArray(extend)) {
                        if (confs.contains(tok)) {
                            throw new IllegalArgumentException(
                                    "Cannot exclude a configuration which is extended.");
                        }
                    }
                }

                write("<" + qName);
                for (int i = 0; i < attributes.getLength(); i++) {
                    write(" " + attributes.getQName(i) + "=\""
                            + substitute(settings, attributes.getValue(i)) + "\"");
                }
            }
        }

        private void startElementWithConfAttributes(String qName, Attributes attributes) {
            // copy
            write("<" + qName);
            for (int i = 0; i < attributes.getLength(); i++) {
                String attName = attributes.getQName(i);
                if ("defaultconf".equals(attName) || "defaultconfmapping".equals(attName)) {
                    String newMapping = removeConfigurationsFromMapping(
                        substitute(settings, attributes.getValue(attName)));
                    if (!newMapping.isEmpty()) {
                        write(" " + attName + "=\"" + newMapping + "\"");
                    }
                } else {
                    write(" " + attName + "=\""
                            + substitute(settings, attributes.getValue(i)) + "\"");
                }
            }
            // add default conf if needed
            if (defaultConf != null && attributes.getValue("defaultconf") == null) {
                String newConf = removeConfigurationsFromMapping(defaultConf);
                if (!newConf.isEmpty()) {
                    write(" defaultconf=\"" + newConf + "\"");
                }
            }
            // add default conf mapping if needed
            if (defaultConfMapping != null && attributes.getValue("defaultconfmapping") == null) {
                String newMapping = removeConfigurationsFromMapping(defaultConfMapping);
                if (!newMapping.isEmpty()) {
                    write(" defaultconfmapping=\"" + newMapping + "\"");
                }
            }
            // add confmappingoverride if needed
            if (confMappingOverride != null && attributes.getValue("confmappingoverride") == null) {
                write(" confmappingoverride=\"" + confMappingOverride.toString() + "\"");
            }
        }

        private void startPublications(Attributes attributes) {
            write("<publications");
            for (int i = 0; i < attributes.getLength(); i++) {
                String attName = attributes.getQName(i);
                if ("defaultconf".equals(attName)) {
                    newDefaultConf = removeConfigurationsFromList(
                        substitute(settings, attributes.getValue("defaultconf")));
                    if (!newDefaultConf.isEmpty()) {
                        write(" " + attName + "=\"" + newDefaultConf + "\"");
                    }
                } else {
                    write(" " + attName + "=\""
                            + substitute(settings, attributes.getValue(i)) + "\"");
                }
            }
        }

        private void startElementInDependency(Attributes attributes) {
            ExtendedBuffer buffer = new ExtendedBuffer(getContext());
            buffers.push(buffer);
            confAttributeBuffers.push(buffer);
            buffer.setDefaultPrint(isNullOrEmpty(attributes.getValue("conf")));
            write("<dependency");
            String org = substitute(settings, attributes.getValue("org"));
            if (org == null) {
                org = organisation;
            }
            String module = substitute(settings, attributes.getValue("name"));
            String branch = substitute(settings, attributes.getValue("branch"));
            String branchConstraint = substitute(settings, attributes.getValue("branchConstraint"));
            if (branchConstraint == null) {
                branchConstraint = branch;
            }

            // look for the branch used in resolved revisions
            if (branch == null) {
                ModuleId mid = ModuleId.newInstance(org, module);
                if (ns != null) {
                    mid = NameSpaceHelper.transform(mid, ns.getToSystemTransformer());
                }
                for (ModuleRevisionId mrid : resolvedRevisions.keySet()) {
                    if (mrid.getModuleId().equals(mid)) {
                        branch = mrid.getBranch();
                        break;
                    }
                }
            }

            String revision = substitute(settings, attributes.getValue("rev"));
            String revisionConstraint = substitute(settings, attributes.getValue("revConstraint"));
            Map<String, String> extraAttributes = ExtendableItemHelper.getExtraAttributes(settings, attributes,
                XmlModuleDescriptorParser.DEPENDENCY_REGULAR_ATTRIBUTES);
            ModuleRevisionId localMrid = ModuleRevisionId.newInstance(org, module, branch,
                revision, extraAttributes);
            ModuleRevisionId systemMrid = (ns == null) ? localMrid : ns.getToSystemTransformer()
                    .transform(localMrid);

            String newBranch = resolvedBranches.get(systemMrid);

            for (int i = 0; i < attributes.getLength(); i++) {
                String attName = attributes.getQName(i);
                switch (attName) {
                    case "org":
                        write(" org=\"" + systemMrid.getOrganisation() + "\"");
                        break;
                    case "name":
                        write(" name=\"" + systemMrid.getName() + "\"");
                        break;
                    case "rev":
                        String rev = resolvedRevisions.get(systemMrid);
                        if (rev == null) {
                            write(" rev=\"" + systemMrid.getRevision() + "\"");
                        } else {
                            write(" rev=\"" + rev + "\"");
                            if (attributes.getIndex("branchConstraint") == -1
                                    && branchConstraint != null) {
                                write(" branchConstraint=\"" + branchConstraint + "\"");
                            }
                            if (generateRevConstraint && attributes.getIndex("revConstraint") == -1
                                    && !rev.equals(systemMrid.getRevision())) {
                                write(" revConstraint=\"" + systemMrid.getRevision() + "\"");
                            }
                        }
                        break;
                    case "revConstraint":
                        write(" revConstraint=\"" + revisionConstraint + "\"");
                        break;
                    case "branch":
                        if (newBranch != null) {
                            write(" branch=\"" + newBranch + "\"");
                        } else if (!resolvedBranches.containsKey(systemMrid)) {
                            write(" branch=\"" + systemMrid.getBranch() + "\"");
                        } else {
                            // if resolvedBranches contains the systemMrid, but the new branch is null,
                            // the branch attribute will be removed altogether
                        }
                        break;
                    case "branchConstraint":
                        write(" branchConstraint=\"" + branchConstraint + "\"");
                        break;
                    case "conf":
                        String oldMapping = substitute(settings, attributes.getValue("conf"));
                        if (!oldMapping.isEmpty()) {
                            String newMapping = removeConfigurationsFromMapping(oldMapping);
                            if (!newMapping.isEmpty()) {
                                write(" conf=\"" + newMapping + "\"");
                                buffers.peek().setPrint(true);
                            }
                        }
                        break;
                    default:
                        write(" " + attName + "=\""
                                + substitute(settings, attributes.getValue(attName)) + "\"");
                        break;
                }
            }

            if (attributes.getIndex("branch") == -1) {
                // erase an existing branch attribute if its new value is blank
                if (!isNullOrEmpty(newBranch)) {
                    write(" branch=\"" + newBranch + "\"");
                } else if (options.isUpdateBranch() && systemMrid.getBranch() != null) {
                    // this dependency is on a specific branch, we set it explicitly in the updated
                    // file
                    write(" branch=\"" + systemMrid.getBranch() + "\"");
                }
            }
        }

        private void includeStarted(Attributes attributes) throws SAXException {
            final ExtendedBuffer buffer = new ExtendedBuffer(getContext());
            buffers.push(buffer);
            try {
                URL url;
                if (settings == null) {
                    // TODO : settings can be null, but I don't why.
                    // Check if the following code is correct in that case
                    String fileName = attributes.getValue("file");
                    if (fileName == null) {
                        String urlStr = attributes.getValue("url");
                        url = new URL(urlStr);
                    } else {
                        url = Checks.checkAbsolute(fileName, "settings.include").toURI().toURL();
                    }
                } else {
                    url = settings.getRelativeUrlResolver().getURL(relativePathCtx,
                        settings.substitute(attributes.getValue("file")),
                        settings.substitute(attributes.getValue("url")));
                }
                XMLHelper.parse(url, null, new DefaultHandler() {
                    private boolean insideConfigurations = false;

                    private boolean doIndent = false;

                    public void startElement(String uri, String localName, String qName,
                            Attributes attributes) throws SAXException {
                        if ("configurations".equals(qName)) {
                            insideConfigurations = true;
                            String defaultconf = substitute(settings,
                                attributes.getValue("defaultconf"));
                            if (defaultconf != null) {
                                defaultConf = defaultconf;
                            }
                            String defaultMapping = substitute(settings,
                                attributes.getValue("defaultconfmapping"));
                            if (defaultMapping != null) {
                                defaultConfMapping = defaultMapping;
                            }
                            String mappingOverride = substitute(settings,
                                attributes.getValue("confmappingoverride"));
                            if (mappingOverride != null) {
                                confMappingOverride = Boolean.valueOf(mappingOverride);
                            }
                        } else if ("conf".equals(qName) && insideConfigurations) {
                            String confName = substitute(settings, attributes.getValue("name"));
                            if (!confs.contains(confName)) {
                                buffer.setPrint(true);
                                if (doIndent) {
                                    write("/>\n\t\t");
                                }
                                String extend = substitute(settings, attributes.getValue("extends"));
                                if (extend != null) {
                                    for (String tok : splitToArray(extend)) {
                                        if (confs.contains(tok)) {
                                            throw new IllegalArgumentException(
                                                    "Cannot exclude a configuration which is extended.");
                                        }
                                    }
                                }

                                write("<" + qName);
                                for (int i = 0; i < attributes.getLength(); i++) {
                                    write(" " + attributes.getQName(i) + "=\""
                                            + substitute(settings, attributes.getValue(i)) + "\"");
                                }
                                doIndent = true;
                            }
                        }
                    }

                    public void endElement(String uri, String localName, String name)
                            throws SAXException {
                        if ("configurations".equals(name)) {
                            insideConfigurations = false;
                        }
                    }
                });
            } catch (Exception e) {
                Message.warn("exception occurred while importing configurations: " + e.getMessage());
                throw new SAXException(e);
            }
        }

        private void infoStarted(Attributes attributes) {

            String module = substitute(settings, attributes.getValue("module"));
            String rev = null;
            String branch = null;
            String status = null;
            String namespace = null;
            Map<String, String> extraAttributes = null;

            if (options.isMerge()) {
                // get attributes from merged descriptor, ignoring raw XML
                ModuleDescriptor merged = options.getMergedDescriptor();
                ModuleRevisionId mergedMrid = merged.getModuleRevisionId();
                organisation = mergedMrid.getOrganisation();
                branch = mergedMrid.getBranch();
                rev = mergedMrid.getRevision();
                status = merged.getStatus();

                // TODO: should namespace be added to ModuleDescriptor interface, so we don't
                // have to do this kind of check?
                if (merged instanceof DefaultModuleDescriptor) {
                    Namespace ns = ((DefaultModuleDescriptor) merged).getNamespace();
                    if (ns != null) {
                        namespace = ns.getName();
                    }
                }
                if (namespace == null) {
                    namespace = attributes.getValue("namespace");
                }

                extraAttributes = merged.getQualifiedExtraAttributes();
            } else {
                // get attributes from raw XML, performing property substitution
                organisation = substitute(settings, attributes.getValue("organisation"));
                rev = substitute(settings, attributes.getValue("revision"));
                branch = substitute(settings, attributes.getValue("branch"));
                status = substitute(settings, attributes.getValue("status"));
                namespace = substitute(settings, attributes.getValue("namespace"));
                extraAttributes = new LinkedHashMap<>(attributes.getLength());
                for (int i = 0; i < attributes.getLength(); i++) {
                    String qname = attributes.getQName(i);
                    if (!STD_ATTS.contains(qname)) {
                        extraAttributes.put(qname, substitute(settings, attributes.getValue(i)));
                    }
                }
            }

            // apply override values provided in options
            if (revision != null) {
                rev = revision;
            }
            if (options.getBranch() != null) {
                branch = options.getBranch();
            }
            if (this.status != null) {
                status = this.status;
            }

            // if necessary translate mrid using optional namespace argument
            ModuleRevisionId localMid = ModuleRevisionId.newInstance(organisation, module, branch,
                rev, ExtendableItemHelper.getExtraAttributes(settings, attributes,
                            Arrays.asList("organisation", "module", "revision", "status",
                                    "publication", "namespace")));
            ModuleRevisionId systemMid = (ns == null) ? localMid : ns.getToSystemTransformer()
                    .transform(localMid);

            write("<info");
            if (organisation != null) {
                write(" organisation=\"" + XMLHelper.escape(systemMid.getOrganisation()) + "\"");
            }
            write(" module=\"" + XMLHelper.escape(systemMid.getName()) + "\"");
            if (branch != null) {
                write(" branch=\"" + XMLHelper.escape(systemMid.getBranch()) + "\"");
            }
            if (systemMid.getRevision() != null) {
                write(" revision=\"" + XMLHelper.escape(systemMid.getRevision()) + "\"");
            }
            write(" status=\"" + XMLHelper.escape(status) + "\"");
            if (pubdate != null) {
                write(" publication=\"" + DateUtil.format(pubdate) + "\"");
            } else if (attributes.getValue("publication") != null) {
                write(" publication=\"" + substitute(settings, attributes.getValue("publication"))
                        + "\"");
            }
            if (namespace != null) {
                write(" namespace=\"" + namespace + "\"");
            }

            for (Map.Entry<String, String> extra : extraAttributes.entrySet()) {
                write(" " + extra.getKey() + "=\"" + extra.getValue() + "\"");
            }
        }

        private void write(String content) {
            getWriter().print(content);
        }

        private PrintWriter getWriter() {
            return buffers.isEmpty() ? out : buffers.peek().getWriter();
        }

        private String getContext() {
            return joinArray(context.toArray(new String[context.size()]), "/");
        }

        private String substitute(ParserSettings ivy, String value) {
            String result = (ivy == null) ? value : ivy.substitute(value);
            return XMLHelper.escape(result);
        }

        private String removeConfigurationsFromMapping(String mapping) {
            StringBuilder newMapping = new StringBuilder();
            String mappingSep = "";
            for (String groups : mapping.trim().split("\\s*;\\s*")) {
                String[] ops = groups.split("->");
                List<String> confsToWrite = new ArrayList<>();
                for (String lh : splitToArray(ops[0])) {
                    if (!confs.contains(lh)) {
                        confsToWrite.add(lh);
                    }
                }
                if (!confsToWrite.isEmpty()) {
                    newMapping.append(mappingSep);
                    String sep = "";
                    String listSep = groups.contains(", ") ? ", " : ",";
                    for (String confToWrite : confsToWrite) {
                        newMapping.append(sep).append(confToWrite);
                        sep = listSep;
                    }
                    if (ops.length == 2) {
                        newMapping.append("->").append(joinArray(splitToArray(ops[1]), sep));
                    }
                    mappingSep = ";";
                }
            }
            return newMapping.toString();
        }

        private String removeConfigurationsFromList(String list) {
            StringBuilder newList = new StringBuilder();
            String sep = "";
            String listSep = list.contains(", ") ? ", " : ",";
            for (String current : splitToArray(list)) {
                if (!confs.contains(current)) {
                    newList.append(sep).append(current);
                    sep = listSep;
                }
            }
            return newList.toString();
        }

        public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
            characters(ch, start, length);
        }

        public void characters(char[] ch, int start, int length) throws SAXException {
            if (justOpen != null) {
                write(">");
                justOpen = null;
            }
            write(XMLHelper.escape(String.valueOf(ch, start, length)));

            // examine characters for current indent level, keeping in mind
            // that our indent might be split across multiple calls to characters()
            for (int i = start, end = start + length; i < end; ++i) {
                char c = ch[i];
                if (c == '\r' || c == '\n') {
                    // newline resets the indent level
                    currentIndent.setLength(0);
                    indenting = true;
                } else if (indenting) {
                    // indent continues until first non-whitespace character
                    if (Character.isWhitespace(c)) {
                        currentIndent.append(c);
                    } else {
                        endIndent();
                    }
                }
            }
        }

        /** record the current indent level for future elements that appear at the same depth */
        private void endIndent() {
            if (indenting) {
                // record the indent at this level. if we insert any elements at
                // this level, we'll use the same indent.
                setIndent(context.size() - 1, currentIndent.toString());
                indenting = false;
            }
        }

        /**
         * Set the indent for the given depth. Indents less than the provided depth will be
         * calculated automatically, if they have not already been defined.
         */
        private void setIndent(int level, String indent) {
            fillIndents(level);
            indentLevels.set(level, indent);
        }

        /**
         * Guarantee that indent levels have been calculated up to and including the given depth
         * (starting at 0).
         */
        private void fillIndents(int level) {
            if (indentLevels.isEmpty()) {
                // add a default single-level indent until we see indents in the document
                indentLevels.add("    ");
            }
            String oneLevel = indentLevels.get(0);
            for (int fill = indentLevels.size(); fill <= level; ++fill) {
                indentLevels.add(indentLevels.get(fill - 1) + oneLevel);
            }
        }

        /**
         * get the whitespace that should precede new elements at the current depth in the document
         */
        private String getIndent() {
            int level = context.size() - 1;
            fillIndents(level);
            return indentLevels.get(level);
        }

        /**
         * Write XML elements that do not appear in the source descriptor, but have been copied in
         * from a parent module descriptor via &lt;extends&gt; declaration.
         *
         * @param merged
         *            child descriptor containing the merged data
         * @param items
         *            the list of inherited items to print
         * @param printer
         *            a printer that knows how to write the given type of item
         * @param itemName
         *            the name of the container element, e.g. "configurations"
         * @param includeContainer
         *            if true, include an enclosing element named <code>itemName</code>. Otherwise
         *            just write the inherited items inline, with a comment indicating where they
         *            came from.
         */
        private void writeInheritedItems(ModuleDescriptor merged, InheritableItem[] items,
                ItemPrinter printer, String itemName, boolean includeContainer) {
            // first categorize inherited items by their source module, so that
            // we can add some useful comments
            PrintWriter out = getWriter();

            Map<ModuleRevisionId, List<InheritableItem>> inheritedItems = collateInheritedItems(
                merged, items);
            boolean hasItems = !inheritedItems.isEmpty();

            if (hasItems && includeContainer) {
                if (currentIndent.length() == 0) {
                    out.print(getIndent());
                }
                String newConf = (defaultConf == null) ? ""
                        : removeConfigurationsFromMapping(defaultConf);
                String newMapping = (defaultConfMapping == null) ? ""
                        : removeConfigurationsFromMapping(defaultConfMapping);
                out.print(String.format("<%s%s%s%s>", itemName,
                        newConf.isEmpty() ? "" : " defaultconf=\"" + newConf + "\"",
                        newMapping.isEmpty() ? "" : " defaultconfmapping=\"" + newMapping + "\"",
                        (confMappingOverride != null) ? " confmappingoverride=\"" + confMappingOverride + "\"" : ""));
                context.push(itemName);
                justOpen = null;
            }

            for (Map.Entry<ModuleRevisionId, List<InheritableItem>> entry : inheritedItems
                    .entrySet()) {
                if (justOpen != null) {
                    out.println(">");
                    justOpen = null; // helps endElement() decide how to write close tags
                }
                writeInheritanceComment(itemName, entry.getKey());
                for (InheritableItem item : entry.getValue()) {
                    out.print(getIndent());
                    printer.print(merged, item, out);
                }
            }

            if (hasItems) {
                if (includeContainer) {
                    context.pop();
                    out.println(getIndent() + "</" + itemName + ">");
                    out.println();
                }
                // restore the prior indent
                out.print(currentIndent);
            }
        }

        private void writeInheritanceComment(String itemDescription, Object parentInfo) {
            PrintWriter out = getWriter();
            out.println();
            out.println(getIndent() + "<!-- " + itemDescription + " inherited from " + parentInfo
                    + " -->");
        }

        /**
         * Collect the given list of inherited descriptor items into lists keyed by parent Id. Thus
         * all of the items inherited from parent A can be written together, then all of the items
         * from parent B, and so on.
         *
         * @param merged
         *            the merged child descriptor
         * @param items
         *            the inherited items to collate
         * @return maps parent ModuleRevisionId to a List of InheritedItems imported from that
         *         parent
         */
        private Map<ModuleRevisionId, List<InheritableItem>> collateInheritedItems(
                ModuleDescriptor merged, InheritableItem[] items) {
            Map<ModuleRevisionId, List<InheritableItem>> inheritedItems = new LinkedHashMap<>();
            for (InheritableItem item : items) {
                ModuleRevisionId source = item.getSourceModule();
                // ignore items that are defined directly in the child descriptor
                if (source != null
                        && !source.getModuleId().equals(merged.getModuleRevisionId().getModuleId())) {
                    List<InheritableItem> accum = inheritedItems.get(source);
                    if (accum == null) {
                        accum = new ArrayList<>();
                        inheritedItems.put(source, accum);
                    }
                    accum.add(item);
                }
            }
            return inheritedItems;
        }

        /**
         * If no info/description element has yet been written, write the description inherited from
         * the parent descriptor, if any. Calling this method more than once has no affect.
         */
        private void writeInheritedDescription(ModuleDescriptor merged) {
            if (!hasDescription) {
                hasDescription = true;
                String description = merged.getDescription();
                if (!isNullOrEmpty(description)) {
                    PrintWriter writer = getWriter();
                    if (justOpen != null) {
                        writer.println(">");
                    }
                    writeInheritanceComment("description", "parent");
                    writer.println(getIndent() + "<description>" + XMLHelper.escape(description)
                            + "</description>");
                    // restore the indent that existed before we wrote the extra elements
                    writer.print(currentIndent);
                    justOpen = null;
                }
            }
        }

        private void writeInheritedConfigurations(ModuleDescriptor merged) {
            if (!mergedConfigurations) {
                mergedConfigurations = true;
                writeInheritedItems(merged, merged.getConfigurations(),
                    ConfigurationPrinter.INSTANCE, "configurations", false);
            }
        }

        private void writeInheritedDependencies(ModuleDescriptor merged) {
            if (!mergedDependencies) {
                mergedDependencies = true;
                writeInheritedItems(merged, getDependencies(merged), DependencyPrinter.INSTANCE,
                    "dependencies", false);
            }
        }

        private DependencyDescriptor[] getDependencies(ModuleDescriptor merged) {
          DependencyDescriptor[] dependencies = merged.getDependencies();
          for (int i = 0; i < dependencies.length; i += 1) {
            ModuleRevisionId mrid = dependencies[i].getDependencyRevisionId();
            String rev = resolvedRevisions.get(mrid); // IVY-1410
            if (rev != null && !rev.equals(mrid.getRevision())) {
              dependencies[i] = dependencies[i].clone(ModuleRevisionId.newInstance(mrid, rev));
            }
          }
          return dependencies;
        }

        /**
         * <p>
         * If publishing in merge mode, guarantee that any merged elements appearing before
         * <code>moduleElement</code> have been written. This method should be called <i>before</i>
         * we write the start tag of <code>moduleElement</code>. This covers cases where merged
         * elements like "configurations" and "dependencies" appear in the parent descriptor, but
         * are completely missing in the child descriptor.
         * </p>
         *
         * <p>
         * For example, if "moduleElement" is "dependencies", guarantees that "configurations" has
         * been written. If <code>moduleElement</code> is <code>null</code>, then all missing merged
         * elements will be flushed.
         * </p>
         *
         * @param moduleElement
         *            a descriptor element name, for example "configurations" or "info"
         */
        private void flushMergedElementsBefore(String moduleElement) {
            if (options.isMerge() && context.size() == 1 && "ivy-module".equals(context.peek())
                    && !(mergedConfigurations && mergedDependencies)) {

                // calculate the position of the element in ivy-module
                int position = (moduleElement == null) ? MODULE_ELEMENTS.size()
                        : MODULE_ELEMENTS.indexOf(moduleElement);

                ModuleDescriptor merged = options.getMergedDescriptor();

                // see if we should write <configurations>
                if (!mergedConfigurations && position > CONFIGURATIONS_POSITION
                        && merged.getConfigurations().length > 0) {

                    mergedConfigurations = true;
                    writeInheritedItems(merged, merged.getConfigurations(),
                        ConfigurationPrinter.INSTANCE, "configurations", true);

                }
                // see if we should write <dependencies>
                if (!mergedDependencies && position > DEPENDENCIES_POSITION
                        && merged.getDependencies().length > 0) {

                    mergedDependencies = true;
                    writeInheritedItems(merged, merged.getDependencies(),
                        DependencyPrinter.INSTANCE, "dependencies", true);

                }
            }
        }

        private void flushAllMergedElements() {
            flushMergedElementsBefore(null);
        }

        public void endElement(String uri, String localName, String qName) throws SAXException {

            String path = getContext();
            if (options.isMerge()) {
                ModuleDescriptor merged = options.getMergedDescriptor();
                switch (path) {
                    case "ivy-module/info":
                        // guarantee that inherited description has been written before
                        // info element closes.
                        writeInheritedDescription(merged);
                        break;
                    case "ivy-module/configurations":
                        // write inherited configurations after all child configurations
                        writeInheritedConfigurations(merged);
                        break;
                    case "ivy-module/dependencies":
                        // write inherited dependencies after all child dependencies
                        writeInheritedDependencies(merged);
                        break;
                    case "ivy-module":
                        // write any remaining inherited data before we close the
                        // descriptor.
                        flushAllMergedElements();
                        break;
                }
            }

            if (qName.equals(justOpen)) {
                write("/>");
            } else {
                write("</" + qName + ">");
            }

            if (!buffers.isEmpty()) {
                ExtendedBuffer buffer = buffers.peek();
                if (buffer.getContext().equals(path)) {
                    buffers.pop();
                    if (buffer.isPrint()) {
                        write(buffer.toString());
                    }
                }
            }

            if (!confAttributeBuffers.isEmpty()) {
                ExtendedBuffer buffer = confAttributeBuffers.peek();
                if (buffer.getContext().equals(path)) {
                    confAttributeBuffers.pop();
                }
            }

            // <extends> element is commented out when in merge mode.
            if (options.isMerge() && "ivy-module/info/extends".equals(path)) {
                write(" -->");
            }

            justOpen = null;
            context.pop();
        }

        public void endDocument() throws SAXException {
            out.print(LINE_SEPARATOR);
            out.flush();
            out.close();
        }

        public void processingInstruction(String target, String data) throws SAXException {
            write("<?");
            write(target);
            write(" ");
            write(data);
            write("?>");
            write(LINE_SEPARATOR);
        }

        public void warning(SAXParseException e) throws SAXException {
            throw e;
        }

        public void error(SAXParseException e) throws SAXException {
            throw e;
        }

        public void fatalError(SAXParseException e) throws SAXException {
            throw e;
        }

        public void endCDATA() throws SAXException {
        }

        public void endDTD() throws SAXException {
        }

        public void startCDATA() throws SAXException {
        }

        public void comment(char[] ch, int start, int length) throws SAXException {
            if (justOpen != null) {
                write(">");
                justOpen = null;
            }

            write("<!--");
            write(String.valueOf(ch, start, length));
            write("-->");

            if (inHeader) {
                write(LINE_SEPARATOR);
            }
        }

        public void endEntity(String name) throws SAXException {
        }

        public void startEntity(String name) throws SAXException {
        }

        public void startDTD(String name, String publicId, String systemId) throws SAXException {
        }

    }

    public static void update(URL inStreamCtx, InputStream inStream, OutputStream outStream,
            final UpdateOptions options) throws IOException, SAXException {
        final PrintWriter out = new PrintWriter(new OutputStreamWriter(outStream, StandardCharsets.UTF_8));
        out.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
        out.write(LINE_SEPARATOR);

        try {
            UpdaterHandler updaterHandler = new UpdaterHandler(inStreamCtx, out, options);
            InputSource inSrc = new InputSource(new BufferedInputStream(inStream));
            if (inStreamCtx != null) {
                inSrc.setSystemId(inStreamCtx.toExternalForm());
            }
            XMLHelper.parse(inSrc, null, updaterHandler, updaterHandler);
        } catch (ParserConfigurationException e) {
            throw new IllegalStateException("impossible to update Ivy files: parser problem", e);
        }
    }

    private static class ExtendedBuffer {
        private String context = null;

        private Boolean print = null;

        private boolean defaultPrint = false;

        private StringWriter buffer = new StringWriter();

        private PrintWriter writer = new PrintWriter(buffer);

        ExtendedBuffer(String context) {
            this.context = context;
        }

        boolean isPrint() {
            if (print == null) {
                return defaultPrint;
            }
            return print;
        }

        void setPrint(boolean print) {
            this.print = print;
        }

        void setDefaultPrint(boolean print) {
            this.defaultPrint = print;
        }

        PrintWriter getWriter() {
            return writer;
        }

        String getContext() {
            return context;
        }

        public String toString() {
            writer.flush();
            return buffer.toString();
        }
    }

    /**
     * Prints a descriptor item's XML representation
     */
    protected interface ItemPrinter {
        /**
         * Print an XML representation of <code>item</code> to <code>out</code>.
         *
         * @param parent
         *            the module descriptor containing <code>item</code>
         * @param item
         *            subcomponent of the descriptor, for example a {@link DependencyDescriptor} or
         *            {@link Configuration}
         * @param out PrintWriter
         */
        void print(ModuleDescriptor parent, Object item, PrintWriter out);
    }

    protected static class DependencyPrinter implements ItemPrinter {

        public static final DependencyPrinter INSTANCE = new DependencyPrinter();

        public void print(ModuleDescriptor parent, Object item, PrintWriter out) {
            XmlModuleDescriptorWriter.printDependency(parent, (DependencyDescriptor) item, out);
        }
    }

    protected static class ConfigurationPrinter implements ItemPrinter {

        public static final ConfigurationPrinter INSTANCE = new ConfigurationPrinter();

        public void print(ModuleDescriptor parent, Object item, PrintWriter out) {
            XmlModuleDescriptorWriter.printConfiguration((Configuration) item, out);
        }
    }
}
