//
//  ========================================================================
//  Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//

package org.eclipse.jetty.xml;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.AbstractList;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Stack;
import java.util.StringTokenizer;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.eclipse.jetty.util.LazyList;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.resource.Resource;
import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;

/**
 * XML Parser wrapper. This class wraps any standard JAXP1.1 parser with convieniant error and
 * entity handlers and a mini dom-like document tree.
 * <p>
 * By default, the parser is created as a validating parser only if xerces is present. This can be
 * configured by setting the "org.eclipse.jetty.xml.XmlParser.Validating" system property.
 */
public class XmlParser
{
    private static final Logger LOG = Log.getLogger(XmlParser.class);

    private Map<String, URL> _redirectMap = new HashMap<String, URL>();
    private SAXParser _parser;
    private Map<String, ContentHandler> _observerMap;
    private Stack<ContentHandler> _observers = new Stack<ContentHandler>();
    private String _xpath;
    private Object _xpaths;
    private String _dtd;

    /**
     * Construct
     */
    public XmlParser()
    {
        SAXParserFactory factory = SAXParserFactory.newInstance();
        boolean validatingDft = factory.getClass().toString().startsWith("org.apache.xerces.");
        String validatingProp = System.getProperty("org.eclipse.jetty.xml.XmlParser.Validating", validatingDft ? "true" : "false");
        boolean validating = Boolean.valueOf(validatingProp).booleanValue();
        setValidating(validating);
    }

    public XmlParser(boolean validating)
    {
        setValidating(validating);
    }

    public void setValidating(boolean validating)
    {
        try
        {
            SAXParserFactory factory = SAXParserFactory.newInstance();
            factory.setValidating(validating);
            _parser = factory.newSAXParser();

            try
            {
                if (validating)
                    _parser.getXMLReader().setFeature("http://apache.org/xml/features/validation/schema", validating);
            }
            catch (Exception e)
            {
                if (validating)
                    LOG.warn("Schema validation may not be supported: ", e);
                else
                    LOG.ignore(e);
            }

            _parser.getXMLReader().setFeature("http://xml.org/sax/features/validation", validating);
            _parser.getXMLReader().setFeature("http://xml.org/sax/features/namespaces", true);
            _parser.getXMLReader().setFeature("http://xml.org/sax/features/namespace-prefixes", false);
            try
            {
                if (validating)
                    _parser.getXMLReader().setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", validating);
            }
            catch (Exception e)
            {
                LOG.warn(e.getMessage());
            }
        }
        catch (Exception e)
        {
            LOG.warn(Log.EXCEPTION, e);
            throw new Error(e.toString());
        }
    }

    public boolean isValidating()
    {
        return _parser.isValidating();
    }

    public synchronized void redirectEntity(String name, URL entity)
    {
        if (entity != null)
            _redirectMap.put(name, entity);
    }

    /**
     * @return Returns the xpath.
     */
    public String getXpath()
    {
        return _xpath;
    }

    /**
     * Set an XPath A very simple subset of xpath is supported to select a partial tree. Currently
     * only path like "/node1/nodeA | /node1/nodeB" are supported.
     *
     * @param xpath The xpath to set.
     */
    public void setXpath(String xpath)
    {
        _xpath = xpath;
        StringTokenizer tok = new StringTokenizer(xpath, "| ");
        while (tok.hasMoreTokens())
        {
            _xpaths = LazyList.add(_xpaths, tok.nextToken());
        }
    }

    public String getDTD()
    {
        return _dtd;
    }

    /**
     * Add a ContentHandler. Add an additional _content handler that is triggered on a tag name. SAX
     * events are passed to the ContentHandler provided from a matching start element to the
     * corresponding end element. Only a single _content handler can be registered against each tag.
     *
     * @param trigger Tag local or q name.
     * @param observer SAX ContentHandler
     */
    public synchronized void addContentHandler(String trigger, ContentHandler observer)
    {
        if (_observerMap == null)
            _observerMap = new HashMap<>();
        _observerMap.put(trigger, observer);
    }

    public synchronized Node parse(InputSource source) throws IOException, SAXException
    {
        _dtd = null;
        Handler handler = new Handler();
        XMLReader reader = _parser.getXMLReader();
        reader.setContentHandler(handler);
        reader.setErrorHandler(handler);
        reader.setEntityResolver(handler);
        if (LOG.isDebugEnabled())
            LOG.debug("parsing: sid=" + source.getSystemId() + ",pid=" + source.getPublicId());
        _parser.parse(source, handler);
        if (handler._error != null)
            throw handler._error;
        Node doc = (Node)handler._top.get(0);
        handler.clear();
        return doc;
    }

    /**
     * Parse String URL.
     *
     * @param url the url to the xml to parse
     * @return the root node of the xml
     * @throws IOException if unable to load the xml
     * @throws SAXException if unable to parse the xml
     */
    public synchronized Node parse(String url) throws IOException, SAXException
    {
        if (LOG.isDebugEnabled())
            LOG.debug("parse: " + url);
        return parse(new InputSource(url));
    }

    /**
     * Parse File.
     *
     * @param file the file to the xml to parse
     * @return the root node of the xml
     * @throws IOException if unable to load the xml
     * @throws SAXException if unable to parse the xml
     */
    public synchronized Node parse(File file) throws IOException, SAXException
    {
        if (LOG.isDebugEnabled())
            LOG.debug("parse: " + file);
        return parse(new InputSource(Resource.toURL(file).toString()));
    }

    /**
     * Parse InputStream.
     *
     * @param in the input stream of the xml to parse
     * @return the root node of the xml
     * @throws IOException if unable to load the xml
     * @throws SAXException if unable to parse the xml
     */
    public synchronized Node parse(InputStream in) throws IOException, SAXException
    {
        _dtd = null;
        Handler handler = new Handler();
        XMLReader reader = _parser.getXMLReader();
        reader.setContentHandler(handler);
        reader.setErrorHandler(handler);
        reader.setEntityResolver(handler);
        _parser.parse(new InputSource(in), handler);
        if (handler._error != null)
            throw handler._error;
        Node doc = (Node)handler._top.get(0);
        handler.clear();
        return doc;
    }

    protected InputSource resolveEntity(String pid, String sid)
    {
        if (LOG.isDebugEnabled())
            LOG.debug("resolveEntity(" + pid + ", " + sid + ")");

        if (sid != null && sid.endsWith(".dtd"))
            _dtd = sid;

        URL entity = null;
        if (pid != null)
            entity = _redirectMap.get(pid);
        if (entity == null)
            entity = _redirectMap.get(sid);
        if (entity == null)
        {
            String dtd = sid;
            if (dtd.lastIndexOf('/') >= 0)
                dtd = dtd.substring(dtd.lastIndexOf('/') + 1);

            if (LOG.isDebugEnabled())
                LOG.debug("Can't exact match entity in redirect map, trying " + dtd);
            entity = _redirectMap.get(dtd);
        }

        if (entity != null)
        {
            try
            {
                InputStream in = entity.openStream();
                if (LOG.isDebugEnabled())
                    LOG.debug("Redirected entity " + sid + " --> " + entity);
                InputSource is = new InputSource(in);
                is.setSystemId(sid);
                return is;
            }
            catch (IOException e)
            {
                LOG.ignore(e);
            }
        }
        return null;
    }

    private class NoopHandler extends DefaultHandler
    {
        Handler _next;
        int _depth;

        NoopHandler(Handler next)
        {
            this._next = next;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attrs) throws SAXException
        {
            _depth++;
        }

        @Override
        public void endElement(String uri, String localName, String qName) throws SAXException
        {
            if (_depth == 0)
                _parser.getXMLReader().setContentHandler(_next);
            else
                _depth--;
        }
    }

    private class Handler extends DefaultHandler
    {
        Node _top = new Node(null, null, null);
        SAXParseException _error;
        private Node _context = _top;
        private NoopHandler _noop;

        Handler()
        {
            _noop = new NoopHandler(this);
        }

        void clear()
        {
            _top = null;
            _error = null;
            _context = null;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attrs) throws SAXException
        {
            String name = null;
            if (_parser.isNamespaceAware())
                name = localName;

            if (name == null || "".equals(name))
                name = qName;

            Node node = new Node(_context, name, attrs);

            // check if the node matches any xpaths set?
            if (_xpaths != null)
            {
                String path = node.getPath();
                boolean match = false;
                for (int i = LazyList.size(_xpaths); !match && i-- > 0; )
                {
                    String xpath = LazyList.get(_xpaths, i);

                    match = path.equals(xpath) || xpath.startsWith(path) && xpath.length() > path.length() && xpath.charAt(path.length()) == '/';
                }

                if (match)
                {
                    _context.add(node);
                    _context = node;
                }
                else
                {
                    _parser.getXMLReader().setContentHandler(_noop);
                }
            }
            else
            {
                _context.add(node);
                _context = node;
            }

            ContentHandler observer = null;
            if (_observerMap != null)
                observer = _observerMap.get(name);
            _observers.push(observer);

            for (int i = 0; i < _observers.size(); i++)
            {
                if (_observers.get(i) != null)
                    _observers.get(i).startElement(uri, localName, qName, attrs);
            }
        }

        @Override
        public void endElement(String uri, String localName, String qName) throws SAXException
        {
            _context = _context._parent;
            for (int i = 0; i < _observers.size(); i++)
            {
                if (_observers.get(i) != null)
                    _observers.get(i).endElement(uri, localName, qName);
            }
            _observers.pop();
        }

        @Override
        public void ignorableWhitespace(char[] buf, int offset, int len) throws SAXException
        {
            for (int i = 0; i < _observers.size(); i++)
            {
                if (_observers.get(i) != null)
                    _observers.get(i).ignorableWhitespace(buf, offset, len);
            }
        }

        @Override
        public void characters(char[] buf, int offset, int len) throws SAXException
        {
            _context.add(new String(buf, offset, len));
            for (int i = 0; i < _observers.size(); i++)
            {
                if (_observers.get(i) != null)
                    _observers.get(i).characters(buf, offset, len);
            }
        }

        @Override
        public void warning(SAXParseException ex)
        {
            LOG.debug(Log.EXCEPTION, ex);
            LOG.warn("WARNING@" + getLocationString(ex) + " : " + ex.toString());
        }

        @Override
        public void error(SAXParseException ex) throws SAXException
        {
            // Save error and continue to report other errors
            if (_error == null)
                _error = ex;
            LOG.debug(Log.EXCEPTION, ex);
            LOG.warn("ERROR@" + getLocationString(ex) + " : " + ex.toString());
        }

        @Override
        public void fatalError(SAXParseException ex) throws SAXException
        {
            _error = ex;
            LOG.debug(Log.EXCEPTION, ex);
            LOG.warn("FATAL@" + getLocationString(ex) + " : " + ex.toString());
            throw ex;
        }

        private String getLocationString(SAXParseException ex)
        {
            return ex.getSystemId() + " line:" + ex.getLineNumber() + " col:" + ex.getColumnNumber();
        }

        @Override
        public InputSource resolveEntity(String pid, String sid)
        {
            return XmlParser.this.resolveEntity(pid, sid);
        }
    }

    /**
     * XML Attribute.
     */
    public static class Attribute
    {
        private String _name;
        private String _value;

        Attribute(String n, String v)
        {
            _name = n;
            _value = v;
        }

        public String getName()
        {
            return _name;
        }

        public String getValue()
        {
            return _value;
        }
    }

    /**
     * XML Node. Represents an XML element with optional attributes and ordered content.
     */
    public static class Node extends AbstractList<Object>
    {
        Node _parent;
        private ArrayList<Object> _list;
        private String _tag;
        private Attribute[] _attrs;
        private boolean _lastString = false;
        private String _path;

        Node(Node parent, String tag, Attributes attrs)
        {
            _parent = parent;
            _tag = tag;

            if (attrs != null)
            {
                _attrs = new Attribute[attrs.getLength()];
                for (int i = 0; i < attrs.getLength(); i++)
                {
                    String name = attrs.getLocalName(i);
                    if (name == null || name.equals(""))
                        name = attrs.getQName(i);
                    _attrs[i] = new Attribute(name, attrs.getValue(i));
                }
            }
        }

        public Node getParent()
        {
            return _parent;
        }

        public String getTag()
        {
            return _tag;
        }

        public String getPath()
        {
            if (_path == null)
            {
                if (getParent() != null && getParent().getTag() != null)
                    _path = getParent().getPath() + "/" + _tag;
                else
                    _path = "/" + _tag;
            }
            return _path;
        }

        /**
         * Get an array of element attributes.
         *
         * @return the attributes
         */
        public Attribute[] getAttributes()
        {
            return _attrs;
        }

        /**
         * Get an element attribute.
         *
         * @param name the name of the attribute
         * @return attribute or null.
         */
        public String getAttribute(String name)
        {
            return getAttribute(name, null);
        }

        /**
         * Get an element attribute.
         *
         * @param name the name of the element
         * @param dft the default value
         * @return attribute or null.
         */
        public String getAttribute(String name, String dft)
        {
            if (_attrs == null || name == null)
                return dft;
            for (int i = 0; i < _attrs.length; i++)
            {
                if (name.equals(_attrs[i].getName()))
                    return _attrs[i].getValue();
            }
            return dft;
        }

        /**
         * Get the number of children nodes.
         */
        @Override
        public int size()
        {
            if (_list != null)
                return _list.size();
            return 0;
        }

        /**
         * Get the ith child node or content.
         *
         * @return Node or String.
         */
        @Override
        public Object get(int i)
        {
            if (_list != null)
                return _list.get(i);
            return null;
        }

        /**
         * Get the first child node with the tag.
         *
         * @param tag the name of the tag
         * @return Node or null.
         */
        public Node get(String tag)
        {
            if (_list != null)
            {
                for (int i = 0; i < _list.size(); i++)
                {
                    Object o = _list.get(i);
                    if (o instanceof Node)
                    {
                        Node n = (Node)o;
                        if (tag.equals(n._tag))
                            return n;
                    }
                }
            }
            return null;
        }

        @Override
        public void add(int i, Object o)
        {
            if (_list == null)
                _list = new ArrayList<Object>();
            if (o instanceof String)
            {
                if (_lastString)
                {
                    int last = _list.size() - 1;
                    _list.set(last, (String)_list.get(last) + o);
                }
                else
                    _list.add(i, o);
                _lastString = true;
            }
            else
            {
                _lastString = false;
                _list.add(i, o);
            }
        }

        @Override
        public void clear()
        {
            if (_list != null)
                _list.clear();
            _list = null;
        }

        /**
         * Get a tag as a string.
         *
         * @param tag The tag to get
         * @param tags IF true, tags are included in the value.
         * @param trim If true, trim the value.
         * @return results of get(tag).toString(tags).
         */
        public String getString(String tag, boolean tags, boolean trim)
        {
            Node node = get(tag);
            if (node == null)
                return null;
            String s = node.toString(tags);
            if (s != null && trim)
                s = s.trim();
            return s;
        }

        @Override
        public synchronized String toString()
        {
            return toString(true);
        }

        /**
         * Convert to a string.
         *
         * @param tag If false, only _content is shown.
         * @return the string value
         */
        public synchronized String toString(boolean tag)
        {
            StringBuilder buf = new StringBuilder();
            toString(buf, tag);
            return buf.toString();
        }

        /**
         * Convert to a string.
         *
         * @param tag If false, only _content is shown.
         * @param trim true to trim the content
         * @return the trimmed content
         */
        public synchronized String toString(boolean tag, boolean trim)
        {
            String s = toString(tag);
            if (s != null && trim)
                s = s.trim();
            return s;
        }

        private synchronized void toString(StringBuilder buf, boolean tag)
        {
            if (tag)
            {
                buf.append("<");
                buf.append(_tag);

                if (_attrs != null)
                {
                    for (int i = 0; i < _attrs.length; i++)
                    {
                        buf.append(' ');
                        buf.append(_attrs[i].getName());
                        buf.append("=\"");
                        buf.append(_attrs[i].getValue());
                        buf.append("\"");
                    }
                }
            }

            if (_list != null)
            {
                if (tag)
                    buf.append(">");
                for (int i = 0; i < _list.size(); i++)
                {
                    Object o = _list.get(i);
                    if (o == null)
                        continue;
                    if (o instanceof Node)
                        ((Node)o).toString(buf, tag);
                    else
                        buf.append(o.toString());
                }
                if (tag)
                {
                    buf.append("</");
                    buf.append(_tag);
                    buf.append(">");
                }
            }
            else if (tag)
                buf.append("/>");
        }

        /**
         * Iterator over named child nodes.
         *
         * @param tag The tag of the nodes.
         * @return Iterator over all child nodes with the specified tag.
         */
        public Iterator<Node> iterator(final String tag)
        {
            return new Iterator<Node>()
            {
                int c = 0;
                Node _node;

                @Override
                public boolean hasNext()
                {
                    if (_node != null)
                        return true;
                    while (_list != null && c < _list.size())
                    {
                        Object o = _list.get(c);
                        if (o instanceof Node)
                        {
                            Node n = (Node)o;
                            if (tag.equals(n._tag))
                            {
                                _node = n;
                                return true;
                            }
                        }
                        c++;
                    }
                    return false;
                }

                @Override
                public Node next()
                {
                    try
                    {
                        if (hasNext())
                            return _node;
                        throw new NoSuchElementException();
                    }
                    finally
                    {
                        _node = null;
                        c++;
                    }
                }

                @Override
                public void remove()
                {
                    throw new UnsupportedOperationException("Not supported");
                }
            };
        }
    }
}
