/*
 *  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.m2;

import org.apache.ivy.core.IvyPatternHelper;
import org.apache.ivy.core.module.descriptor.License;
import org.apache.ivy.core.module.id.ModuleId;
import org.apache.ivy.core.module.id.ModuleRevisionId;
import org.apache.ivy.plugins.repository.Resource;
import org.apache.ivy.util.XMLHelper;
import org.apache.ivy.util.url.URLHandlerRegistry;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;

/**
 * Provides the method to read some data out of the DOM tree of a pom file.
 */
public class PomReader {

    private static final String PROFILES_ELEMENT = "profiles";

    private static final String PACKAGING = "packaging";

    private static final String DEPENDENCY = "dependency";

    private static final String DEPENDENCIES = "dependencies";

    private static final String DEPENDENCY_MGT = "dependencyManagement";

    private static final String PROJECT = "project";

    private static final String MODEL = "model";

    private static final String GROUP_ID = "groupId";

    private static final String ARTIFACT_ID = "artifactId";

    private static final String VERSION = "version";

    private static final String DESCRIPTION = "description";

    private static final String HOMEPAGE = "url";

    private static final String LICENSES = "licenses";

    private static final String LICENSE = "license";

    private static final String LICENSE_NAME = "name";

    private static final String LICENSE_URL = "url";

    private static final String PARENT = "parent";

    private static final String SCOPE = "scope";

    private static final String CLASSIFIER = "classifier";

    private static final String OPTIONAL = "optional";

    private static final String EXCLUSIONS = "exclusions";

    private static final String EXCLUSION = "exclusion";

    private static final String DISTRIBUTION_MGT = "distributionManagement";

    private static final String RELOCATION = "relocation";

    private static final String PROPERTIES = "properties";

    private static final String PLUGINS = "plugins";

    private static final String PLUGIN = "plugin";

    private static final String TYPE = "type";

    private static final String PROFILE = "profile";

    private final Map<String, String> properties = new HashMap<>();

    private final Element projectElement;

    private final Element parentElement;

    @SuppressWarnings("deprecation")
    public PomReader(final URL descriptorURL, final Resource res) throws IOException, SAXException {
        InputStream stream = new AddDTDFilterInputStream(
                URLHandlerRegistry.getDefault().openStream(descriptorURL));
        InputSource source = new InputSource(stream);
        source.setSystemId(XMLHelper.toSystemId(descriptorURL));
        try {
            Document pomDomDoc = XMLHelper.parseToDom(source, new EntityResolver() {
                public InputSource resolveEntity(String publicId, String systemId)
                        throws SAXException, IOException {
                    if (systemId != null && systemId.endsWith("m2-entities.ent")) {
                        // IVY-921: return an InputSource for our local packaged m2-entities.ent file
                        return new InputSource(
                                PomReader.class.getResourceAsStream("m2-entities.ent"));
                    }
                    return null;
                }
            }, true, XMLHelper.ExternalResources.IGNORE);
            projectElement = pomDomDoc.getDocumentElement();
            if (!PROJECT.equals(projectElement.getNodeName())
                    && !MODEL.equals(projectElement.getNodeName())) {
                throw new SAXParseException("project must be the root tag", res.getName(),
                        res.getName(), 0, 0);
            }
            parentElement = getFirstChildElement(projectElement, PARENT);
        } finally {
            try {
                stream.close();
            } catch (IOException e) {
                // ignore
            }
        }
        // Both environment and system properties take precedence over properties set in
        // pom.xml. So we pre-populate our properties with the environment and system properties
        // here
        for (final Map.Entry<String, String> envEntry : System.getenv().entrySet()) {
            // Maven let's users use "env." prefix for environment variables
            this.setProperty("env." + envEntry.getKey(), envEntry.getValue());
        }
        // add system properties
        final Properties sysProps = System.getProperties();
        for (final String sysProp : sysProps.stringPropertyNames()) {
            this.setProperty(sysProp, sysProps.getProperty(sysProp));
        }
    }

    public boolean hasParent() {
        return parentElement != null;
    }

    /**
     * Add a property if not yet set and value is not null. This guarantees
     * that property keeps the first value that is put on it and that the
     * properties are never null.
     *
     * @param prop String
     * @param val  String
     */
    public void setProperty(String prop, String val) {
        if (!properties.containsKey(prop) && val != null) {
            properties.put(prop, val);
        }
    }

    public String getGroupId() {
        String groupId = getFirstChildText(projectElement, GROUP_ID);
        if (groupId == null) {
            groupId = getFirstChildText(parentElement, GROUP_ID);
        }
        return replaceProps(groupId);

    }

    public String getParentGroupId() {
        String groupId = getFirstChildText(parentElement, GROUP_ID);
        if (groupId == null) {
            groupId = getFirstChildText(projectElement, GROUP_ID);
        }
        return replaceProps(groupId);
    }

    public String getArtifactId() {
        String val = getFirstChildText(projectElement, ARTIFACT_ID);
        if (val == null) {
            val = getFirstChildText(parentElement, ARTIFACT_ID);
        }
        return replaceProps(val);
    }

    public String getParentArtifactId() {
        String val = getFirstChildText(parentElement, ARTIFACT_ID);
        if (val == null) {
            val = getFirstChildText(projectElement, ARTIFACT_ID);
        }
        return replaceProps(val);
    }

    public String getVersion() {
        String val = getFirstChildText(projectElement, VERSION);
        if (val == null) {
            val = getFirstChildText(parentElement, VERSION);
        }
        return replaceProps(val);
    }

    public String getParentVersion() {
        String val = getFirstChildText(parentElement, VERSION);
        if (val == null) {
            val = getFirstChildText(projectElement, VERSION);
        }
        return replaceProps(val);
    }

    public String getPackaging() {
        final String val = getFirstChildText(projectElement, PACKAGING);
        if (val == null) {
            return "jar";
        }
        return replaceProps(val);
    }

    public String getHomePage() {
        String val = getFirstChildText(projectElement, HOMEPAGE);
        if (val == null) {
            val = "";
        }
        return val;
    }

    public String getDescription() {
        String val = getFirstChildText(projectElement, DESCRIPTION);
        if (val == null) {
            val = "";
        }
        return val.trim();
    }

    public License[] getLicenses() {
        Element licenses = getFirstChildElement(projectElement, LICENSES);
        if (licenses == null) {
            return new License[0];
        }
        licenses.normalize();
        List<License> lics = new ArrayList<>();
        for (Element license : getAllChilds(licenses)) {
            if (LICENSE.equals(license.getNodeName())) {
                String name = getFirstChildText(license, LICENSE_NAME);
                String url = getFirstChildText(license, LICENSE_URL);

                if (name == null && url == null) {
                    // move to next license
                    continue;
                }

                if (name == null) {
                    // The license name is required in Ivy but not in a POM!
                    name = "Unknown License";
                }

                lics.add(new License(name, url));
            }
        }
        return lics.toArray(new License[lics.size()]);
    }

    public ModuleRevisionId getRelocation() {
        Element distrMgt = getFirstChildElement(projectElement, DISTRIBUTION_MGT);
        Element relocation = getFirstChildElement(distrMgt, RELOCATION);
        if (relocation == null) {
            return null;
        } else {
            String relocGroupId = getFirstChildText(relocation, GROUP_ID);
            if (relocGroupId == null) {
                relocGroupId = getGroupId();
            }
            String relocArtId = getFirstChildText(relocation, ARTIFACT_ID);
            if (relocArtId == null) {
                relocArtId = getArtifactId();
            }
            String relocVersion = getFirstChildText(relocation, VERSION);
            if (relocVersion == null) {
                relocVersion = getVersion();
            }
            return ModuleRevisionId.newInstance(relocGroupId, relocArtId, relocVersion);
        }
    }

    public List<PomDependencyData> getDependencies() {
        return getDependencies(projectElement);
    }

    private List<PomDependencyData> getDependencies(Element parent) {
        Element dependenciesElement = getFirstChildElement(parent, DEPENDENCIES);
        if (dependenciesElement == null) {
            return Collections.emptyList();
        }
        List<PomDependencyData> dependencies = new LinkedList<>();
        NodeList children = dependenciesElement.getChildNodes();
        for (int i = 0, sz = children.getLength(); i < sz; i++) {
            Node node = children.item(i);
            if (node instanceof Element && DEPENDENCY.equals(node.getNodeName())) {
                dependencies.add(new PomDependencyData((Element) node));
            }
        }
        return dependencies;
    }

    public List<PomDependencyMgt> getDependencyMgt() {
        return getDependencyMgt(projectElement);
    }

    private List<PomDependencyMgt> getDependencyMgt(Element parent) {
        Element dependenciesElement = getFirstChildElement(
                getFirstChildElement(parent, DEPENDENCY_MGT), DEPENDENCIES);
        if (dependenciesElement == null) {
            return Collections.emptyList();
        }
        List<PomDependencyMgt> dependencies = new LinkedList<>();
        NodeList children = dependenciesElement.getChildNodes();
        for (int i = 0, sz = children.getLength(); i < sz; i++) {
            Node node = children.item(i);
            if (node instanceof Element && DEPENDENCY.equals(node.getNodeName())) {
                dependencies.add(new PomDependencyMgtElement((Element) node));
            }
        }
        return dependencies;
    }

    public List<PomProfileElement> getProfiles() {
        Element profilesElement = getFirstChildElement(projectElement, PROFILES_ELEMENT);
        if (profilesElement == null) {
            return Collections.emptyList();
        }
        List<PomProfileElement> result = new LinkedList<>();
        NodeList children = profilesElement.getChildNodes();
        for (int i = 0, sz = children.getLength(); i < sz; i++) {
            Node node = children.item(i);
            if (node instanceof Element && PROFILE.equals(node.getNodeName())) {
                result.add(new PomProfileElement((Element) node));
            }
        }
        return result;
    }

    public class PomDependencyMgtElement implements PomDependencyMgt {
        private final Element depElement;

        public PomDependencyMgtElement(PomDependencyMgtElement copyFrom) {
            this(copyFrom.depElement);
        }

        PomDependencyMgtElement(Element depElement) {
            this.depElement = depElement;
        }

        /*
         * (non-Javadoc)
         *
         * @see org.apache.ivy.plugins.parser.m2.PomDependencyMgt#getGroupId()
         */
        public String getGroupId() {
            String val = getFirstChildText(depElement, GROUP_ID);
            return replaceProps(val);
        }

        /*
         * (non-Javadoc)
         *
         * @see org.apache.ivy.plugins.parser.m2.PomDependencyMgt#getArtifaceId()
         */
        public String getArtifactId() {
            String val = getFirstChildText(depElement, ARTIFACT_ID);
            return replaceProps(val);
        }

        @Override
        public String getType() {
            String val = getFirstChildText(depElement, TYPE);
            return emptyIsNull(replaceProps(val));
        }

        @Override
        public String getClassifier() {
            String val = getFirstChildText(depElement, CLASSIFIER);
            return emptyIsNull(replaceProps(val));
        }

        /*
         * (non-Javadoc)
         *
         * @see org.apache.ivy.plugins.parser.m2.PomDependencyMgt#getVersion()
         */
        public String getVersion() {
            String val = getFirstChildText(depElement, VERSION);
            return replaceProps(val);
        }

        public String getScope() {
            String val = getFirstChildText(depElement, SCOPE);
            return emptyIsNull(replaceProps(val));
        }

        public List<ModuleId> getExcludedModules() {
            Element exclusionsElement = getFirstChildElement(depElement, EXCLUSIONS);
            if (exclusionsElement == null) {
                return Collections.emptyList();
            }
            List<ModuleId> exclusions = new LinkedList<>();
            NodeList children = exclusionsElement.getChildNodes();
            for (int i = 0, sz = children.getLength(); i < sz; i++) {
                Node node = children.item(i);
                if (node instanceof Element && EXCLUSION.equals(node.getNodeName())) {
                    String groupId = getFirstChildText((Element) node, GROUP_ID);
                    String artifactId = getFirstChildText((Element) node, ARTIFACT_ID);
                    if (groupId != null && artifactId != null) {
                        exclusions.add(ModuleId.newInstance(groupId, artifactId));
                    }
                }
            }
            return exclusions;
        }

        /**
         * We return null where certain elements within a pom don't have a value specified.
         * For example, there are pom.xml out there which just use "<classifier/>" in the dependencies.
         * (dependencies in org.seleniumhq.selenium:selenium-java:3.141.59 are one such example)
         * We do this so that callers of such elements don't have to keep repeating checks for empty value.
         * For us an empty value, for many of such elements, is really the same as that element not being specified
         *
         * @param val The value to check
         * @return
         */
        private String emptyIsNull(final String val) {
            if (val == null) {
                return null;
            }
            return val.isEmpty() ? null : val;
        }
    }

    public List<PomPluginElement> getPlugins() {
        return getPlugins(projectElement);
    }

    private List<PomPluginElement> getPlugins(Element parent) {
        Element buildElement = getFirstChildElement(parent, "build");
        Element pluginsElement = getFirstChildElement(buildElement, PLUGINS);

        if (pluginsElement == null) {
            return Collections.emptyList();
        }
        NodeList children = pluginsElement.getChildNodes();
        List<PomPluginElement> plugins = new LinkedList<>();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node instanceof Element && PLUGIN.equals(node.getNodeName())) {
                plugins.add(new PomPluginElement((Element) node));
            }
        }
        return plugins;
    }

    private static Map<String, String> getProperties(final Element parent) {
        final Element propsEl = getFirstChildElement(parent, PROPERTIES);
        if (propsEl == null) {
            return Collections.emptyMap();
        }
        propsEl.normalize();
        final Map<String, String> props = new HashMap<>();
        for (final Element prop : getAllChilds(propsEl)) {
            props.put(prop.getNodeName(), getTextContent(prop));
        }
        return props;
    }

    public class PomPluginElement implements PomDependencyMgt {
        private Element pluginElement;

        PomPluginElement(Element pluginElement) {
            this.pluginElement = pluginElement;
        }

        public String getGroupId() {
            String val = getFirstChildText(pluginElement, GROUP_ID);
            return replaceProps(val);
        }

        public String getArtifactId() {
            String val = getFirstChildText(pluginElement, ARTIFACT_ID);
            return replaceProps(val);
        }

        public String getVersion() {
            String val = getFirstChildText(pluginElement, VERSION);
            return replaceProps(val);
        }

        @Override
        public String getType() {
            return null;
        }

        @Override
        public String getClassifier() {
            return null;
        }

        public String getScope() {
            return null; // not used
        }

        public List<ModuleId> getExcludedModules() {
            return Collections.emptyList(); // probably not used?
        }
    }

    public class PomDependencyData extends PomDependencyMgtElement {
        private final Element depElement;

        public PomDependencyData(PomDependencyData copyFrom) {
            this(copyFrom.depElement);
        }

        PomDependencyData(Element depElement) {
            super(depElement);
            this.depElement = depElement;
        }

        public boolean isOptional() {
            return Boolean.parseBoolean(getFirstChildText(depElement, OPTIONAL));
        }
    }

    public class PomProfileElement {

        private static final String VALUE = "value";

        private static final String NAME = "name";

        private static final String PROPERTY = "property";

        private static final String ID_ELEMENT = "id";

        private static final String ACTIVATION_ELEMENT = "activation";

        private static final String ACTIVE_BY_DEFAULT_ELEMENT = "activeByDefault";

        private static final String OS = "os";

        private static final String FAMILY = "family";

        private static final String VERSION = "version";

        private static final String ARCH = "arch";

        private static final String FILE = "file";

        private static final String MISSING = "missing";

        private static final String EXISTS = "exists";

        private static final String JDK = "jdk";

        private final Element profileElement;

        PomProfileElement(Element profileElement) {
            this.profileElement = profileElement;
        }

        public String getId() {
            return getFirstChildText(profileElement, ID_ELEMENT);
        }

        public boolean isActive() {
            return isActiveByDefault() || isActivatedByProperty()
                    || isActiveByOS() || isActiveByJDK() || isActiveByFile();
        }

        public boolean isActiveByDefault() {
            Element activation = getFirstChildElement(profileElement, ACTIVATION_ELEMENT);
            return Boolean.parseBoolean(getFirstChildText(activation, ACTIVE_BY_DEFAULT_ELEMENT));
        }

        public boolean isActiveByOS() {
            final Element activation = getFirstChildElement(profileElement, ACTIVATION_ELEMENT);
            if (activation == null) {
                return false;
            }
            final Element osActivation = getFirstChildElement(activation, OS);
            if (osActivation == null) {
                return false;
            }
            final String actualOS = System.getProperty("os.name");
            final String expectedOSName = getFirstChildText(osActivation, NAME);
            if (expectedOSName != null && !actualOS.equals(expectedOSName.trim())) {
                // os name is specified but doesn't match
                return false;
            }
            final String expectedOSFamily = getFirstChildText(osActivation, FAMILY);
            if (expectedOSFamily != null && !actualOS.contains(expectedOSFamily.trim())) {
                // os family is specified but doesn't match
                return false;
            }
            final String expectedOSArch = getFirstChildText(osActivation, ARCH);
            if (expectedOSArch != null && !System.getProperty("os.arch").equals(expectedOSArch.trim())) {
                // os arch is specified but doesn't match
                return false;
            }
            final String expectedOSVersion = getFirstChildText(osActivation, VERSION);
            if (expectedOSVersion != null && !System.getProperty("os.version").equals(expectedOSVersion.trim())) {
                // os version is specified but doesn't match
                return false;
            }
            // reaching here implies that either no OS match rules were specified or
            // all of the OS rules that were specified were matched. So we just check to see
            // if any rules were specified at all, in which case, we consider the profile to be activated
            // by the OS element
            return (expectedOSName != null || expectedOSFamily != null || expectedOSArch != null || expectedOSVersion != null);
        }

        public boolean isActiveByJDK() {
            final Element activation = getFirstChildElement(profileElement, ACTIVATION_ELEMENT);
            if (activation == null) {
                return false;
            }
            final String expectedJDKRange = getFirstChildText(activation, JDK);
            if (expectedJDKRange == null) {
                return false;
            }
            final boolean negate = expectedJDKRange.trim().startsWith("!");
            final String nonNegatedRange = negate ? expectedJDKRange.substring(1).trim() : expectedJDKRange.trim();
            final boolean javaVersionInRange = MavenVersionRangeParser.currentJavaVersionInRange(nonNegatedRange);
            return javaVersionInRange ^ negate;
        }

        public boolean isActiveByFile() {
            final Element activation = getFirstChildElement(profileElement, ACTIVATION_ELEMENT);
            if (activation == null) {
                return false;
            }
            final Element fileActivation = getFirstChildElement(activation, FILE);
            if (fileActivation == null) {
                return false;
            }
            final String expectedMissing = getFirstChildText(fileActivation, MISSING);
            if (expectedMissing != null && new File(expectedMissing.trim()).exists()) {
                // the file was specified and expected to be missing, but it exists
                return false;
            }
            final String expectedExists = getFirstChildText(fileActivation, EXISTS);
            if (expectedExists != null && !(new File(expectedExists.trim()).exists())) {
                // the file was specified and expected to be existing, but it doesn't
                return false;
            }
            // reaching here implies that either no file match rules were specified or
            // all of the file rules that were specified were matched. So we just check to see
            // if any rules were specified at all, in which case, we consider the profile to be activated
            // by the file element
            return (expectedMissing != null || expectedExists != null);
        }

        public boolean isActivatedByProperty() {
            Element activation = getFirstChildElement(profileElement, ACTIVATION_ELEMENT);
            Element propertyActivation = getFirstChildElement(activation, PROPERTY);
            String propertyName = getFirstChildText(propertyActivation, NAME);
            if (propertyName == null || "".equals(propertyName)) {
                return false;
            }
            boolean negate = propertyName.charAt(0) == '!';
            if (negate) {
                propertyName = propertyName.substring(1);
            }
            if ("".equals(propertyName)) {
                return false;
            }
            String propertyValue = getFirstChildText(propertyActivation, VALUE);

            final boolean matched;
            if (propertyValue == null || "".equals(propertyValue)) {
                matched = PomReader.this.properties.containsKey(propertyName);
            } else {
                matched = propertyValue.equals(PomReader.this.properties.get(propertyName));
            }
            return matched ^ negate;
        }

        public List<PomDependencyData> getDependencies() {
            return PomReader.this.getDependencies(profileElement);
        }

        public List<PomDependencyMgt> getDependencyMgt() {
            return PomReader.this.getDependencyMgt(profileElement);
        }

        public List<PomPluginElement> getPlugins() {
            return PomReader.this.getPlugins(profileElement);
        }

        public Map<String, String> getProfileProperties() {
            return PomReader.getProperties(profileElement);
        }
    }

    /**
     * @return the content of the properties tag into the pom.
     */
    public Map<String, String> getPomProperties() {
        return new HashMap<>(getProperties(projectElement));
    }

    private String replaceProps(String val) {
        if (val == null) {
            return null;
        } else {
            return IvyPatternHelper.substituteVariables(val, properties).trim();
        }
    }

    private static String getTextContent(Element element) {
        StringBuilder result = new StringBuilder();

        NodeList childNodes = element.getChildNodes();
        for (int i = 0; i < childNodes.getLength(); i++) {
            Node child = childNodes.item(i);

            switch (child.getNodeType()) {
                case Node.CDATA_SECTION_NODE:
                case Node.TEXT_NODE:
                    result.append(child.getNodeValue());
                    break;
                default:
                    break;
            }
        }

        return result.toString();
    }

    private static String getFirstChildText(Element parentElem, String name) {
        Element node = getFirstChildElement(parentElem, name);
        if (node != null) {
            return getTextContent(node);
        } else {
            return null;
        }
    }

    private static Element getFirstChildElement(Element parentElem, String name) {
        if (parentElem == null) {
            return null;
        }
        NodeList childs = parentElem.getChildNodes();
        for (int i = 0; i < childs.getLength(); i++) {
            Node node = childs.item(i);
            if (node instanceof Element && name.equals(node.getNodeName())) {
                return (Element) node;
            }
        }
        return null;
    }

    private static List<Element> getAllChilds(Element parent) {
        List<Element> r = new LinkedList<>();
        if (parent != null) {
            NodeList childs = parent.getChildNodes();
            for (int i = 0; i < childs.getLength(); i++) {
                Node node = childs.item(i);
                if (node instanceof Element) {
                    r.add((Element) node);
                }
            }
        }
        return r;
    }

    private static final class AddDTDFilterInputStream extends FilterInputStream {
        private static final int MARK = 10000;

        private static final String DOCTYPE = "<!DOCTYPE project SYSTEM \"m2-entities.ent\">\n";

        private int count;

        private byte[] prefix = DOCTYPE.getBytes();

        private AddDTDFilterInputStream(InputStream in) throws IOException {
            super(new BufferedInputStream(in));

            this.in.mark(MARK);

            // TODO: we should really find a better solution for this...
            // maybe we could use a FilterReader instead of a FilterInputStream?
            int byte1 = this.in.read();
            int byte2 = this.in.read();
            int byte3 = this.in.read();

            if (byte1 == 239 && byte2 == 187 && byte3 == 191) {
                // skip the UTF-8 BOM
                this.in.mark(MARK);
            } else {
                this.in.reset();
            }

            int bytesToSkip = 0;
            LineNumberReader reader = new LineNumberReader(new InputStreamReader(this.in, StandardCharsets.UTF_8),
                    100);
            String firstLine = reader.readLine();
            if (firstLine != null) {
                String trimmed = firstLine.trim();
                if (trimmed.startsWith("<?xml ")) {
                    int endIndex = trimmed.indexOf("?>");
                    String xmlDecl = trimmed.substring(0, endIndex + 2);
                    prefix = (xmlDecl + "\n" + DOCTYPE).getBytes();
                    bytesToSkip = xmlDecl.getBytes().length;
                }
            } else {
                prefix = new byte[0];
            }

            this.in.reset();
            for (int i = 0; i < bytesToSkip; i++) {
                this.in.read();
            }
        }

        @Override
        public int read() throws IOException {
            if (count < prefix.length) {
                return prefix[count++];
            }

            return super.read();
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            if (b == null) {
                throw new NullPointerException();
            } else if (off < 0 || off > b.length || len < 0 || (off + len) > b.length
                    || (off + len) < 0) {
                throw new IndexOutOfBoundsException();
            } else if (len == 0) {
                return 0;
            }

            int nbrBytesCopied = 0;

            if (count < prefix.length) {
                int nbrBytesFromPrefix = Math.min(prefix.length - count, len);
                System.arraycopy(prefix, count, b, off, nbrBytesFromPrefix);
                nbrBytesCopied = nbrBytesFromPrefix;
            }

            if (nbrBytesCopied < len) {
                nbrBytesCopied += in.read(b, off + nbrBytesCopied, len - nbrBytesCopied);
            }

            count += nbrBytesCopied;
            return nbrBytesCopied;
        }
    }

}
