/**
 * Copyright (C) 2010 Orbeon, Inc.
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU Lesser General Public License as published by the Free Software Foundation; either version
 * 2.1 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Lesser General Public License for more details.
 *
 * The full text of the license is available at http://www.gnu.org/copyleft/lesser.html
 */
package org.orbeon.oxf.xml;

import org.apache.commons.lang3.StringUtils;
import org.orbeon.oxf.common.ValidationException;
import org.orbeon.oxf.xml.dom4j.LocationData;
import org.xml.sax.Attributes;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

import java.util.Stack;

/**
 * Just like ForwardingXMLReceiver (a SAX handler that forwards SAX events to another handler), but
 * checks the validity of the SAX stream.
 *
 * TODO: check for duplicate attributes.
 */
public class InspectingXMLReceiver extends ForwardingXMLReceiver {

    private Locator locator;

    private Stack<NameInfo> elementStack = new Stack<NameInfo>();

    private boolean documentStarted = false;
    private boolean documentEnded = false;

    private NamespaceContext namespaceContext = new NamespaceContext();

    public InspectingXMLReceiver(XMLReceiver xmlReceiver) {
        super(xmlReceiver);
    }

    public void startDocument() throws SAXException {

        if (documentStarted)
            throw new ValidationException("startDocument() called twice", new LocationData(locator));

        documentStarted = true;
        super.startDocument();
    }

    public void endDocument() throws SAXException {
        if (elementStack.size() != 0)
            throw new ValidationException("Document ended before all the elements are closed", new LocationData(locator));

        if (documentEnded)
            throw new ValidationException("endDocument() called twice", new LocationData(locator));

        documentEnded = true;
        super.endDocument();
    }

    public void startElement(String uri, String localname, String qname, Attributes attributes) throws SAXException {
        namespaceContext.startElement();
        final String error = checkInDocument();
        if (error != null)
            throw new ValidationException(error + ": element " + qname, new LocationData(locator));

        elementStack.push(new NameInfo(uri, localname, qname, new AttributesImpl(attributes)));

        // Check names
        checkElementName(uri, localname, qname);
        for (int i = 0; i < attributes.getLength(); i++)
            checkAttributeName(attributes.getURI(i), attributes.getLocalName(i), attributes.getQName(i));

        super.startElement(uri, localname, qname, attributes);
    }

    public void endElement(String uri, String localname, String qname) throws SAXException {
        final String error = checkInElement();
        if (error != null)
            throw new ValidationException(error + ": element " + qname, new LocationData(locator));

        final NameInfo startElementNameInfo = elementStack.pop();
        final NameInfo endElementNameInfo = new NameInfo(uri, localname, qname, null);
        if (!startElementNameInfo.compareNames(endElementNameInfo))
            throw new ValidationException("endElement() doesn't match startElement(). startElement(): "
                    + startElementNameInfo.toString() + "; endElement(): " + endElementNameInfo.toString(), new LocationData(locator));

        // Check name
        checkElementName(uri, localname, qname);

        namespaceContext.endElement();

        super.endElement(uri, localname, qname);
    }

    public void characters(char[] chars, int start, int length) throws SAXException {
        String error = checkInElement();
        if (error != null)
            throw new ValidationException(error + ": '" + new String(chars, start, length) + "'", new LocationData(locator));
        super.characters(chars, start, length);
    }

    public void setDocumentLocator(Locator locator) {
        this.locator = locator;
        super.setDocumentLocator(locator);
    }

    private String checkInElement() {
        String error = checkInDocument();
        if (error != null) {
            return error;
        } else if (elementStack.size() == 0) {
            return "SAX event received after close of root element";
        } else {
            return null;
        }
    }

    private String checkInDocument() {
        if (!documentStarted) {
            return "SAX event received before document start";
        } else if (documentEnded) {
            return "SAX event received after document end";
        } else {
            return null;
        }
    }

    private void checkAttributeName(String uri, String localname, String qname) {
        if (uri != null && qname != null && !"".equals(uri) && qname.indexOf(':') == -1)
            throw new ValidationException("Non-prefixed attribute cannot be in a namespace. URI: " + uri + "; localname: " + localname + "; QName: " + qname, new LocationData(locator));
        checkName(uri, localname, qname);
    }

    private void checkElementName(String uri, String localname, String qname) {
        checkName(uri, localname, qname);
    }

    public void startPrefixMapping(String prefix, String uri) throws SAXException {
        namespaceContext.startPrefixMapping(prefix, uri);

        super.startPrefixMapping(prefix, uri);
    }

    private void checkName(String uri, String localname, String qname) {
        if (StringUtils.isEmpty(localname))
            throw new ValidationException("Empty local name in SAX event. QName: " + qname, new LocationData(locator));
        if (StringUtils.isEmpty(qname))
            throw new ValidationException("Empty qualified name in SAX event. Localname: " + localname + "; QName: " + qname, new LocationData(locator));
        if (uri == null)
            throw new ValidationException("Null URI. Localname: " + localname, new LocationData(locator));
        if (uri.equals("") && !localname.equals(qname))
            throw new ValidationException("Localname and QName must be equal when name is in no namespace. Localname: " + localname + "; QName: " + qname, new LocationData(locator));
        if (!uri.equals("") && !localname.equals(qname.substring(qname.indexOf(':') + 1)))
            throw new ValidationException("Local part or QName must be equal to localname when name is in namespace. Localname: " + localname + "; QName: " + qname, new LocationData(locator));

        final int colonIndex = qname.indexOf(':');
        // Check namespace mappings
        if (!uri.equals("")) {
            // We are in a namespace
            if (colonIndex == -1) {
                // QName is not prefixed, check that we match the default namespace
                if (!uri.equals(namespaceContext.getURI("")))
                    throw new ValidationException("Namespace doesn't match default namespace. Namespace: " + uri + "; QName: " + qname, new LocationData(locator));
            } else if (colonIndex == 0 || colonIndex == (qname.length() - 1)) {
                // Invalid position of colon in QName
                throw new ValidationException("Invalid position of colon in QName: " + qname, new LocationData(locator));
            } else {
                // Name is prefixed: check that prefix is bound and maps to namespace
                final String prefix = qname.substring(0, colonIndex);
                if (namespaceContext.getURI(prefix) == null)
                    throw new ValidationException("QName prefix is not in scope: " + qname, new LocationData(locator));
                if (!uri.equals(namespaceContext.getURI(prefix)))
                    throw new ValidationException("QName prefix maps to URI: " + namespaceContext.getURI(prefix) + "; but namespace provided is: " + uri, new LocationData(locator));
            }
        } else {
            // We are not in a namespace
            if (colonIndex != -1)
                throw new ValidationException("QName has prefix but we are not in a namespace: " + qname, new LocationData(locator));
        }
    }

    private static class NameInfo {
        private String uri;
        private String localname;
        private String qname;
        private AttributesImpl attributes;

        public NameInfo(String uri, String localname, String qname, AttributesImpl attributes) {
            this.uri = uri;
            this.localname = localname;
            this.qname = qname;
            this.attributes = attributes;
        }

        public String getUri() {
            return uri;
        }

        public String getLocalname() {
            return localname;
        }

        public String getQname() {
            return qname;
        }

        public AttributesImpl getAttributes() {
            return attributes;
        }

        public boolean compareNames(NameInfo other) {
            if (!uri.equals(other.uri))
                return false;
            if (!localname.equals(other.localname))
                return false;
            if (!qname.equals(other.qname))
                return false;

            return true;
        }

        public String toString() {
            final StringBuilder sb = new StringBuilder();

            sb.append('[');
            sb.append("uri = ");
            sb.append(uri);
            sb.append(" | localname = ");
            sb.append(localname);
            sb.append(" | qname = ");
            sb.append(qname);
            sb.append(']');

            return sb.toString();
        }
    }
}
