/*******************************************************************************

* Copyright (c) 2006 IONA Technologies PLC

* All rights reserved. This program and the accompanying materials

* are made available under the terms of the Eclipse Public License v1.0

* which accompanies this distribution, and is available at

* http://www.eclipse.org/legal/epl-v10.html

* 

* Contributors:

*     IONA Technologies PLC - initial API and implementation

*******************************************************************************/

package org.eclipse.stp.sc.xmlvalidator.rule.parser;

import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.*;

import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.InputSource;
import org.xml.sax.Attributes;

import java.io.*;
import java.util.*;

import org.eclipse.stp.common.logging.LoggingProxy;

/**
 * The Dom model defined in JAXP does not container line number information associated with each node.
 * However, for the validator to work, we need to report the failed line number in the xml file to the caller.
 * Thus we can integrate with Eclipse problem marker to allow user to double click the error to jump to the
 * error line in xml editor directly.
 * So I wrote this SAX based parser for the special need for our validator. 
 * The output is an DOM document with line number assigned with each node.
 * BTW, you can extend this parser to add more information to the dom during xml parsing if you need.   
 *
 */
public class DomLocationParser {

	/**
	 * Name of the user data used to store the line number.
	 */
    public static final String USER_DATA_LINE_NUMBER = "LineNumber";
    /**
     * Name of the user data used to store the column number.
     */
    public static final String USER_DATA_COLUMN_NUMBER = "ColumnNumber";

    /**
     * Get the line number of an element in the orginal document.
     * @param element an element contained in the tree parsed by this parser.
     * @return the line number if it's defined, -1 otherwise.
     */
    public static int getLineNumber(Element element) {
    	assert(element != null);
    	Integer lineNumber = (Integer)element.getUserData(USER_DATA_LINE_NUMBER);
    	if (lineNumber == null) return -1;
    	return lineNumber.intValue();
    }

    /**
     * Get the line number of an element in the orginal document.
     * @param element an element contained in the tree parsed by this parser.
     * @return the line number if it's defined, -1 otherwise.
     */
    public static int getColumnNumber(Element element) {
    	assert(element != null);
    	Integer columnNumber = (Integer)element.getUserData(USER_DATA_COLUMN_NUMBER);
    	if (columnNumber == null) return -1;
    	return columnNumber.intValue();
    }

    /**
     * Parse an xml document and get a DOM tree with location information.
     * @param is InputStream where the xml document is coming from.
     * @return An instance of Document representing the DOM tree.
     * @throws ParserConfigurationException
     * @throws SAXException
     * @throws IOException
     */
    public Document parse(InputStream is)
                   throws ParserConfigurationException,
                          SAXException,
                          IOException {
           SAXParserFactory saxFactory = SAXParserFactory.newInstance();
           saxFactory.setNamespaceAware(true);
           SAXParser saxParser = saxFactory.newSAXParser();
           MyDefaultHandler dh = new MyDefaultHandler();
           try {
               saxParser.parse(is, dh);
               Document ret = dom;
               return ret;
           }
           finally {
               dom = null;
               dh.releaseResource();
           }
    }

    /**
     * Object used to store DOM representation of an xml document.
     */
    private Document dom = null;
    /**
     * Logging object for class DomLocationParser and its subclasses.
     */
    private static final LoggingProxy LOG = LoggingProxy.getlogger(DomLocationParser.class);

    /**
     * Inner class implementing callback interfaces so as to get the location information.
     * @author ffan
     *
     */
    private class MyDefaultHandler extends DefaultHandler {

        /**
         * Locator used by the underlying parser implementation.
         * We need to save its reference to get the location info.
         */
        private Locator locator = null;

        /**
         * Cursor position (line number) when last callback method was invoked.
         * When startElement() is called, basiclly it's the line number of current element.
         * There's an exception for the first element - previous location is not available.
         */
        private int lastLineNumber = 1;

        /**
         * Cursor position (column number) when last callback method was invoked.
         * When startElement() is called, basiclly it's the line number of current element.
         * There's an exception for the first element - previous location is not available.
         */
        private int lastColumnNumber = 1;

        /**
         * Stack used to push and pop nodes - could be Document or Element.
         */
        LinkedList<Node> nodeStack = new LinkedList<Node>();
        ArrayList<Attr> attrList = new ArrayList<Attr>();

        /**
         * Look backward to get the right location of the first element, if possible.
         * Note that this is a best effort behaviour and there's no any guarantee.
         */
        private void adjustFirstElementLocation(char[] ch, int start, int length) {

        	if (isFirstElementLocationAdjusted) return;
        	if (!need2AdjustFirstElementLocation) return;

        	String content = String.valueOf(ch, 0, start);
            int pos = content.indexOf("<" + firstElement.getTagName());

            if (pos == -1) {
            	need2AdjustFirstElementLocation = false;
            	return;
            }

            int lineNo = locator.getLineNumber();
            for (int i = start + length - 1; i > pos; i--) {
            	if (ch[i] == '\n') lineNo--;
            }

            int columnNo = 1;
            for (int j = pos - 1; j > 0; j--) {
            	if(ch[j] == '\n') break;
            	columnNo++;
            }

            firstElement.setUserData(USER_DATA_LINE_NUMBER, Integer.valueOf(lineNo), null);
            firstElement.setUserData(USER_DATA_COLUMN_NUMBER, Integer.valueOf(columnNo), null);
            
            isFirstElementLocationAdjusted = true;
        }

        /**
         * The first element of an xml document, whose location need to be adjusted.
         */
        private Element firstElement = null;
        /**
         * If the location of the first element is adjusted already, don't adjust again.
         */
        private boolean isFirstElementLocationAdjusted = false;
        /**
         * If it's impossible to adjust location of the first element, don't adjust again.
         */
        private boolean need2AdjustFirstElementLocation = true;

        /**
         * Some ignorable characters (whitespace or CR/LF) are met during the parsing.
         * Default handling, save current location as last location for the next callback.
         */
        public void characters(char[] ch,
                               int start,
                               int length)
                        throws SAXException {
        	LOG.debug("characters()");
            super.characters(ch, start, length);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
            Node node = nodeStack.getLast().getLastChild(); 
            if ( node instanceof Text && node != null) {
            	Text text = (Text)node;
            	text.replaceWholeText(text.getWholeText() + String.valueOf(ch, start, length));
            } else {
            	Text text = dom.createTextNode(String.valueOf(ch, start, length));
            	nodeStack.getLast().appendChild(text);
            }
            adjustFirstElementLocation(ch, start, length);
        }

        /**
         * Parsing of an xml document is to be finished.
         * Remove the last node in the node stack - should be the only one in the stack. 
         */
        public void endDocument()
                         throws SAXException {
        	LOG.debug("endDocument()");
            super.endDocument();
            nodeStack.removeLast();
            assert(nodeStack.isEmpty());
        }

        /**
         * Parsing of an xml document is to be finished.
         * Remove the last node in the node stack - should be the only one in the stack. 
         */
        public void endElement(String uri,
                               String localName,
                               String qName)
                        throws SAXException {
        	LOG.debug("endElement()");
            super.endElement(uri, localName, qName);
            nodeStack.removeLast();
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * End of the prefix mapping.
         */
        public void endPrefixMapping(String prefix)
                              throws SAXException {
        	LOG.debug("endPrefixMapping()");
            super.endPrefixMapping(prefix);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * Ignorable error is met.
         */
        public void error(SAXParseException e)
                   throws SAXException {
        	LOG.debug("error()");
            super.error(e);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * Some ignorable characters (whitespace or CR/LF) are met during the parsing.
         * Default handling, save current location as last location for the next callback.
         */
        public void ignorableWhitespace(char[] ch,
                                        int start,
                                        int length)
                                 throws SAXException {
        	LOG.debug("ignorableWhitespace()");
            super.ignorableWhitespace(ch, start, length);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
            adjustFirstElementLocation(ch, start, length);
        }

        /**
         * Start of notation declaration.
         */
        public void notationDecl(String name,
                                 String publicId,
                                 String systemId)
                          throws SAXException {
        	LOG.debug("notationDecl()");
            super.notationDecl(name, publicId, systemId);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * Processing of instructions.
         */
        public void processingInstruction(String target,
                                          String data)
                                   throws SAXException {
        	LOG.debug("processingInstruction()");
            super.processingInstruction(target, data);
            dom.appendChild(dom.createProcessingInstruction(target, data));
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * External entity is met.
         */
        public InputSource resolveEntity(String publicId,
                                         String systemId)
                                  throws IOException,
                                         SAXException {
        	LOG.debug("resolveEntity()");
            InputSource is = super.resolveEntity(publicId, systemId);
            dom.setXmlStandalone(false);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
            return is;
        }

        /**
         * Underlying parser implementation sets its locator.
         * We save this locator to get the location of current cursor.
         */
        public void setDocumentLocator(Locator locator) {
        	LOG.debug("setDocumentLocator()");
            super.setDocumentLocator(locator);
            this.locator = locator;
        }

        /**
         * Some entity is skipped.
         */
        public void skippedEntity(String name)
                           throws SAXException {
        	LOG.debug("skippedEntity()");
            super.skippedEntity(name);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }
        
        /**
         * Start to parse an xml document.
         */
        public void startDocument()
                           throws SAXException {
        	LOG.debug("startDocument()");
            super.startDocument();
            try {
                dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
            }
            catch (ParserConfigurationException e) {
                throw new SAXException(e);
            }
             
            nodeStack.add(dom);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        public void startElement(String uri,
                                 String localName,
                                 String qName,
                                 Attributes attributes)
                          throws SAXException {
        	LOG.debug("startElement()");
            super.startElement(uri, localName, qName, attributes);

            Node parentNode = nodeStack.getLast();

            Element element =  null;
            if (uri == null || uri.equals("")) {
                element = dom.createElement(qName);
            } else {
                element = dom.createElementNS(uri, qName);
            }

            if (firstElement == null) {
            	firstElement = element;
                element.setUserData(USER_DATA_LINE_NUMBER, Integer.valueOf(locator.getLineNumber()), null);
                element.setUserData(USER_DATA_COLUMN_NUMBER, Integer.valueOf(locator.getColumnNumber()), null);
            } else {
            	element.setUserData(USER_DATA_LINE_NUMBER, Integer.valueOf(lastLineNumber), null);
                element.setUserData(USER_DATA_COLUMN_NUMBER, Integer.valueOf(lastColumnNumber), null);
            }

        	for (int i = 0; i < attributes.getLength(); i++) {
            	if (attributes.getURI(i) == null || attributes.getURI(i).equals("")) {
                    element.setAttribute(attributes.getQName(i), attributes.getValue(i));
            	} else {
            		element.setAttributeNS(attributes.getURI(i), attributes.getQName(i), attributes.getValue(i));
            	}
            }

        	Iterator<Attr> iterator = attrList.iterator();
        	while(iterator.hasNext()) {
        		element.setAttributeNodeNS(iterator.next());
        		iterator.remove();
        	}

        	parentNode.appendChild(element);

            nodeStack.add(element);

            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * Start of prefix mapping.
         */
        public void startPrefixMapping(String prefix,
                                       String uri)
                                throws SAXException {
        	LOG.debug("startPrefixMapping()");
            super.startPrefixMapping(prefix, uri);
            // location when starting prefix mapping is the same as
            // location when starting element, so don't remember it
            // lastLineNumber = locator.getLineNumber();
            // lastColumnNumber = locator.getColumnNumber();
            prefix = prefix.equals("") ? "xmlns" : "xmlns:" + prefix;
            Attr attr = dom.createAttribute(prefix);
            attr.setValue(uri);
            attrList.add(attr);
        }

        /**
         * Unparsed external entity declaration.
         */
        public void unparsedEntityDecl(String name,
                                       String publicId,
                                       String systemId,
                                       String notationName)
                                throws SAXException {
        	LOG.debug("unparsedEntityDecl()");
            super.unparsedEntityDecl(name, publicId, systemId, notationName);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * A warning is met.
         */
        public void warning(SAXParseException e)
                     throws SAXException {
        	LOG.debug("warning()");
            super.warning(e);
            lastLineNumber = locator.getLineNumber();
            lastColumnNumber = locator.getColumnNumber();
        }

        /**
         * Release all resources used in this handler.
         *
         */
        private void releaseResource() {
        	locator = null;
            attrList.clear();
            nodeStack.clear();
            firstElement = null;
        }
    }

}
