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

import org.orbeon.oxf.common.ValidationException;
import org.orbeon.oxf.properties.PropertySet;
import org.orbeon.oxf.xforms.XFormsConstants;
import org.orbeon.oxf.xforms.XFormsProperties;
import org.orbeon.oxf.xforms.XFormsUtils;
import org.orbeon.oxf.xml.*;
import org.orbeon.oxf.xml.dom4j.ExtendedLocationData;
import org.orbeon.oxf.xml.dom4j.LocationData;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;

/**
 * XMLReceiver that:
 *
 * - adds ids on all the XForms elements which don't have one
 * - gathers namespace information on XForms elements (xf:*, xxf:*, exf:*, xbl:*).
 * - finds AVTs on non-XForms elements
 *   - adds ids to those elements
 *   - produces xxf:attribute elements
 * - finds title information and produces xxf:text elements
 *
 * NOTE: There was a thought of merging this with XFormsExtractor but we need a separate annotated document in
 * XFormsToXHTML to produce the output. So if we modify this, we should modify it so that two separate XMLReceiver (at
 * least two separate outputs) are produced, one for the annotated output, another for the extracted output.
 */
public class XFormsAnnotator extends XFormsAnnotatorBase implements XMLReceiver {

    private SAXStore templateSAXStore;

    private int level = 0;
    private boolean inHead;         // whether we are in the HTML head
    private boolean inTitle;        // whether we are in the HTML title

    private boolean inXForms;       // whether we are in a model or other XForms content
    private int xformsLevel;
    private boolean inPreserve;     // whether we are in LHHA, schema or instance
    private int preserveLevel;
    private boolean inLHHA;         // whether we are in LHHA (meaningful only if inPreserve == true)
    private boolean inXBL;          // whether we are in xbl:xbl (meaningful only if inPreserve == true)
    private boolean inXBLBinding;   // whether we are in the body of an XBL binding (meaningful only if inPreserve == true)

    private final boolean topLevel;
    private final Metadata metadata;
    private final boolean isGenerateIds;

    private NamespaceContext namespaceContext = new NamespaceContext();

    private final boolean hostLanguageAVTs = XFormsProperties.isHostLanguageAVTs(); // TODO: this should be obtained per document, but we only know about this in the extractor
    private final AttributesImpl reusableAttributes = new AttributesImpl();
    private final String[] reusableStringArray = new String[1];

    private String htmlTitleElementId;

    @Override
    public boolean isInXBLBinding() {
        return inXBLBinding;
    }

    @Override
    public boolean isInPreserve() {
        return inPreserve;
    }

    /**
     * Constructor for XBL shadow trees and top-level documents.
     *
     * @param templateReceiver      template output (special treatment for marks if this is a SAXStore)
     * @param extractorReceiver     extractor output (can be null for XBL for now)
     * @param metadata              metadata to gather
     */
    public XFormsAnnotator(XMLReceiver templateReceiver, XMLReceiver extractorReceiver, Metadata metadata, boolean topLevel) {
        super(templateReceiver, extractorReceiver);

        this.metadata = metadata;
        this.isGenerateIds = true;
        this.topLevel = topLevel;

        if (templateReceiver instanceof SAXStore)
            this.templateSAXStore = (SAXStore) templateReceiver;
    }

    /**
     * This constructor just computes the namespace mappings and AVT elements and gathers id information.
     *
     * @param metadata              metadata to gather
     */
    public XFormsAnnotator(Metadata metadata) {
        super(null, null);

        // In this mode, all elements that need to have ids already have them, so set safe defaults
        this.metadata = metadata;
        this.isGenerateIds = false;
        this.topLevel = true;
    }

    @Override
    public void startElement(String uri, String localname, String qName, Attributes attributes) throws SAXException {

        namespaceContext.startElement();
        final StackElement stackElement = startElement(uri, localname);

        final int idIndex = attributes.getIndex("id");

        // Entering model or controls
        if (!inXForms && stackElement.isXFormsOrExtension()) {
            inXForms = true;
            xformsLevel = level;
        }

        if (inPreserve) {
            // Within preserved content


            if (inLHHA) {
                // Gather id and namespace information about content of LHHA
                if (stackElement.isXForms()) {
                    // Must be xf:output
                    attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
                } else if (hostLanguageAVTs && hasAVT(attributes)) {
                    // Must be an AVT on an host language element
                    attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
                }
            } else if (inXBL && level - 1 == preserveLevel && stackElement.isXBL() && "binding".equals(localname)) {
                // Gather binding information in xbl:xbl/xbl:binding
                final String elementAttribute = attributes.getValue("element");
                if (elementAttribute != null) {
                    storeXBLBinding(elementAttribute);
                }
                // Gather id and namespace information
                attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
            }
            // Output element
            stackElement.startElement(uri, localname, qName, attributes);
        } else if (stackElement.isXFormsOrExtension()) {
            // This is an XForms element

            // TODO: can we restrain gathering ids / namespaces to only certain elements (all controls + elements with XPath expressions + models + instances)?

            // Create a new id and update the attributes if needed
            attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
            final String xformsElementId = reusableStringArray[0];

            // Handle full update annotation
            if (templateSAXStore != null) {
                // Remember mark if xxf:update="full"
                final String xxformsUpdate = attributes.getValue(XFormsConstants.XXFORMS_UPDATE_QNAME.getNamespaceURI(), XFormsConstants.XXFORMS_UPDATE_QNAME.getName());
                if (XFormsConstants.XFORMS_FULL_UPDATE.equals(xxformsUpdate)) {
                    // Remember this subtree has a full update
                    putMark(xformsElementId);
                    // Add a class to help the client
                    attributes = SAXUtils.appendToClassAttribute(attributes, "xforms-update-full");
                }
            }

            // Rewrite elements / add appearances
            if (inTitle && "output".equals(localname)) {
                // Special case of xf:output within title, which produces an xxf:text control
                attributes = SAXUtils.addOrReplaceAttribute(attributes, "", "", "for", htmlTitleElementId);
                startPrefixMapping2("xxf", XFormsConstants.XXFORMS_NAMESPACE_URI);
                stackElement.startElement(XFormsConstants.XXFORMS_NAMESPACE_URI, "text", "xxf:text", attributes);
            } else if (("group".equals(localname) || "switch".equals(localname)) && doesClosestXHTMLRequireSeparatorAppearance()) {
                // Closest xhtml:* ancestor is xhtml:table|xhtml:tbody|xhtml:thead|xhtml:tfoot|xhtml:tr

                // Append the new xxf:separator appearance
                final String existingAppearance = attributes.getValue("appearance");
                // See: https://github.com/orbeon/orbeon-forms/issues/418
                attributes = SAXUtils.addOrReplaceAttribute(attributes, "", "", XFormsConstants.APPEARANCE_QNAME.getName(),
                        (existingAppearance != null ? existingAppearance + " " : "") + XFormsConstants.XXFORMS_SEPARATOR_APPEARANCE_QNAME.getQualifiedName());
                stackElement.startElement(uri, localname, qName, attributes);
            } else if (stackElement.isXForms() && "repeat".equals(localname)) {
                // Add separator appearance
                if (doesClosestXHTMLRequireSeparatorAppearance()) {
                    final String existingAppearance = attributes.getValue("appearance");
                    attributes = SAXUtils.addOrReplaceAttribute(attributes, "", "", XFormsConstants.APPEARANCE_QNAME.getName(),
                            (existingAppearance != null ? existingAppearance + " " : "") + XFormsConstants.XXFORMS_SEPARATOR_APPEARANCE_QNAME.getQualifiedName());
                }

                // Start xf:repeat
                stackElement.startElement(uri, localname, qName, attributes);

                // Start xf:repeat-iteration
                // NOTE: Use xf:repeat-iteration instead of xxf:iteration so we don't have to deal with a new namespace
                reusableAttributes.clear();
                reusableAttributes.addAttribute("", "id", "id", XMLReceiverHelper.CDATA, xformsElementId + "~iteration");
                final Attributes repeatIterationAttributes = getAttributesGatherNamespaces(uri, qName, reusableAttributes, reusableStringArray, 0);
                stackElement.startElement(uri, localname + "-iteration", qName + "-iteration", repeatIterationAttributes);
            } else {
                // Leave element untouched (except for the id attribute)
                stackElement.startElement(uri, localname, qName, attributes);
            }
        } else if (! stackElement.isXBL() && metadata.isXBLBindingCheckAutomaticBindings(uri, localname)) {
            // Element with a binding

            // Create a new id and update the attributes if needed
            attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
            final String xformsElementId = reusableStringArray[0];

            if (templateSAXStore != null) {
                // Remember mark if xxf:update="full"
                final String xxformsUpdate = attributes.getValue(XFormsConstants.XXFORMS_UPDATE_QNAME.getNamespaceURI(), XFormsConstants.XXFORMS_UPDATE_QNAME.getName());
                if (XFormsConstants.XFORMS_FULL_UPDATE.equals(xxformsUpdate)) {
                    // Remember this subtree has a full update
                    putMark(xformsElementId);
                    // Add a class to help the client
                    attributes = SAXUtils.appendToClassAttribute(attributes, "xforms-update-full");
                }
            }

            // Leave element untouched (except for the id attribute)
            stackElement.startElement(uri, localname, qName, attributes);

            // Don't handle the content
            inPreserve = true;
            inXBLBinding = true;
            preserveLevel = level;

        } else if (stackElement.isXBL()) {
            // This must be xbl:xbl (otherwise we will have isPreserve == true) or xbl:template
            assert localname.equals("xbl") || localname.equals("template") || localname.equals("handler");
            // NOTE: Still process attributes, because the annotator is used to process top-level <xbl:handler> as well.
            attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
            stackElement.startElement(uri, localname, qName, attributes);
        } else {
            // Non-XForms element without an XBL binding

            String htmlElementId = null;

            if (level == 1) {
                if ("head".equals(localname)) {
                    // Entering head
                    inHead = true;
                }
            } else if (level == 2) {
                if (inHead && "title".equals(localname)) {
                    // Entering title
                    inTitle = true;
                    // Make sure there will be an id on the title element (ideally, we would do this only if there is a nested xf:output)
                    attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
                    htmlElementId = reusableStringArray[0];
                    htmlTitleElementId = htmlElementId;
                }
            }

            // NOTE: @id attributes on XHTML elements are rewritten with their effective id during XHTML output by
            // XHTMLElementHandler.
            if ("true".equals(attributes.getValue(XFormsConstants.XXFORMS_NAMESPACE_URI, "control"))) {
                // Non-XForms element which we want to turn into a control (specifically, into a group)

                // Create a new xf:group control which specifies the element name to use. Namespace mappings for the
                // given QName must be in scope as that QName is the original element name.
                final AttributesImpl newAttributes = new AttributesImpl(getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex));
                newAttributes.addAttribute(XFormsConstants.XXFORMS_NAMESPACE_URI, "element", "xxf:element", XMLReceiverHelper.CDATA, qName);

                startPrefixMapping2("xxf", XFormsConstants.XXFORMS_NAMESPACE_URI);
                stackElement.startElement(XFormsConstants.XFORMS_NAMESPACE_URI, "group", "xf:group", newAttributes);
            } else if (hostLanguageAVTs) {
                // This is a non-XForms element and we allow AVTs
                final int attributesCount = attributes.getLength();
                if (attributesCount > 0) {
                    boolean elementWithAVTHasBeenOutput = false;
                    for (int i = 0; i < attributesCount; i++) {
                        final String currentAttributeURI = attributes.getURI(i);
                        if ("".equals(currentAttributeURI) || XMLConstants.XML_URI.equals(currentAttributeURI)) {
                            // For now we only support AVTs on attributes in no namespace or in the XML namespace (for xml:lang)
                            final String attributeValue = attributes.getValue(i);
                            if (XFormsUtils.maybeAVT(attributeValue)) {
                                // This is an AVT
                                final String attributeName = attributes.getQName(i);// use qualified name for xml:lang

                                // Create a new id and update the attributes if needed
                                if (htmlElementId == null) {
                                    attributes = getAttributesGatherNamespaces(uri, qName, attributes, reusableStringArray, idIndex);
                                    htmlElementId = reusableStringArray[0];

                                    // TODO: Clear all attributes having AVTs or XPath expressions will end up in repeat templates.
                                }

                                if (!elementWithAVTHasBeenOutput) {
                                    // Output the element with the new or updated id attribute
                                    stackElement.startElement(uri, localname, qName, attributes);
                                    elementWithAVTHasBeenOutput = true;
                                }

                                // Create a new xxf:attribute control
                                reusableAttributes.clear();

                                final AttributesImpl newAttributes = (AttributesImpl) getAttributesGatherNamespaces(uri, qName, reusableAttributes, reusableStringArray, -1);

                                newAttributes.addAttribute("", "for", "for", XMLReceiverHelper.CDATA, htmlElementId);
                                newAttributes.addAttribute("", "name", "name", XMLReceiverHelper.CDATA, attributeName);
                                newAttributes.addAttribute("", "value", "value", XMLReceiverHelper.CDATA, attributeValue);

                                newAttributes.addAttribute("", "for-name", "for-name", XMLReceiverHelper.CDATA, localname);

                                // These extra attributes can be used alongside src/href attributes
                                if ("src".equals(attributeName) || "href".equals(attributeName)) {
                                    final String urlType = attributes.getValue(XMLConstants.OPS_FORMATTING_URI, "url-type");
                                    final String portletMode = attributes.getValue(XMLConstants.OPS_FORMATTING_URI, "portlet-mode");
                                    final String windowState = attributes.getValue(XMLConstants.OPS_FORMATTING_URI, "window-state");

                                    if (urlType != null)
                                        newAttributes.addAttribute("", "url-type", "url-type", XMLReceiverHelper.CDATA, urlType);
                                    if (portletMode != null)
                                        newAttributes.addAttribute("", "portlet-mode", "portlet-mode", XMLReceiverHelper.CDATA, "portlet-mode");
                                    if (windowState != null)
                                        newAttributes.addAttribute("", "window-state", "window-state", XMLReceiverHelper.CDATA, "window-state");
                                }

                                startPrefixMapping2("xxf", XFormsConstants.XXFORMS_NAMESPACE_URI);
                                stackElement.element(XFormsConstants.XXFORMS_NAMESPACE_URI, "attribute", "xxf:attribute", newAttributes);
                                endPrefixMapping2("xxf");
                            }
                        }
                    }

                    // Output the element as is if no AVT was found
                    if (!elementWithAVTHasBeenOutput)
                        stackElement.startElement(uri, localname, qName, attributes);
                } else {
                    stackElement.startElement(uri, localname, qName, attributes);
                }

            } else {
                // No AVT handling, just output the element
                stackElement.startElement(uri, localname, qName, attributes);
            }
        }

        // Check for preserved content
        if (!inPreserve) {
            if (inXForms) {
                // Preserve as is the content of labels, etc., instances, and schemas
                // Within other xf: check for labels, xf:instance, and xs:schema
                if (stackElement.isXForms()) {
                    inLHHA = XFormsConstants.LABEL_HINT_HELP_ALERT_ELEMENT.contains(localname); // labels, etc. may contain XHTML
                    if (inLHHA || "instance".equals(localname)) {                                  // xf:instance
                        inPreserve = true;
                        preserveLevel = level;
                    }
                } else if ("schema".equals(localname) && XMLConstants.XSD_URI.equals(uri)) {       // xs:schema
                    inPreserve = true;
                    preserveLevel = level;

                }
            } else {
                // At the top-level: check for labels and xbl:xbl
                final boolean isXBLXBL = stackElement.isXBL() && "xbl".equals(localname);
                if (stackElement.isXForms()) {
                    inLHHA = XFormsConstants.LABEL_HINT_HELP_ALERT_ELEMENT.contains(localname); // labels, etc. may contain XHTML
                    if (inLHHA) {
                        inPreserve = true;
                        preserveLevel = level;
                    }
                } else if (isXBLXBL) {// xbl:xbl
                    inPreserve = true;
                    preserveLevel = level;
                    inXBL = true;
                }
            }
        }

        level++;
    }

    @Override
    public void endElement(String uri, String localname, String qName) throws SAXException {

        final StackElement stackElement = endElement();

        level--;

        if (inPreserve && level == preserveLevel) {
            // Leaving preserved content
            inPreserve = false;
            inLHHA = false;
            inXBL = false;
            inXBLBinding = false;
        }

        if (inXForms) {

            if (!inPreserve && stackElement.isXForms() && "repeat".equals(localname)) {
                // Close xf:repeat-iteration
                endElement2(uri, localname + "-iteration", qName + "-iteration");
            }

            if (level == xformsLevel) {
                // Leaving model or controls
                inXForms = false;
            }
        }

        if (level == 1) {
            if ("head".equals(localname)) {
                // Exiting head
                inHead = false;
            } else if ("body".equals(localname)) {
                // Exiting body

                // Add fr:xforms-inspector if requested by property AND if not already present
                final PropertySet propertySet = org.orbeon.oxf.properties.Properties.instance().getPropertySet();
                final String frURI = "http://orbeon.org/oxf/xml/form-runner";
                final String inspectorLocal = "xforms-inspector";
                if (propertySet.getBoolean("oxf.epilogue.xforms.inspector", false) && ! metadata.isXBLBinding(frURI, inspectorLocal)) {

                    // This registers the binding
                    metadata.isXBLBindingCheckAutomaticBindings(frURI, inspectorLocal);

                    final String inspectorPrefix = "fr";
                    final String inspectorQName = XMLUtils.buildQName(inspectorPrefix, inspectorLocal);

                    reusableAttributes.clear();
                    reusableAttributes.addAttribute("", "id", "id", XMLReceiverHelper.CDATA, "orbeon-inspector");
                    final Attributes newAttributes = getAttributesGatherNamespaces(frURI, inspectorQName, reusableAttributes, reusableStringArray, 0);

                    startPrefixMapping2(inspectorPrefix, frURI);
                    stackElement.element(frURI, inspectorLocal, inspectorQName, newAttributes);
                    endPrefixMapping2(inspectorPrefix);
                }
            }
        } else if (level == 2) {
            if ("title".equals(localname)) {
                // Exiting title
                inTitle = false;
            }
        }

        // Close element with name that was used to open it
        stackElement.endElement();

        if (inTitle && stackElement.isXForms() && "output".equals(localname)) {
            endPrefixMapping2("xxf");// for resolving appearance
        }

        namespaceContext.endElement();
    }

    private boolean hasAVT(Attributes attributes) {
        final int attributesCount = attributes.getLength();
        if (attributesCount > 0) {
            for (int i = 0; i < attributesCount; i++) {
                final String currentAttributeURI = attributes.getURI(i);
                if ("".equals(currentAttributeURI) || XMLConstants.XML_URI.equals(currentAttributeURI)) {
                    // For now we only support AVTs on attributes in no namespace or in the XML namespace (for xml:lang)
                    final String attributeValue = attributes.getValue(i);
                    if (XFormsUtils.maybeAVT(attributeValue)) {
                        // This is an AVT
                        return true;
                    }
                }
            }
        }

        return false;
    }

    final private void addNamespaceMapping(String rawId) {
        final Map<String, String> namespaces = new HashMap<String, String>();
        for (Enumeration e = namespaceContext.getPrefixes(); e.hasMoreElements();) {
            final String namespacePrefix = (String) e.nextElement();
            if (!namespacePrefix.startsWith("xml") && !namespacePrefix.equals("")) {
                namespaces.put(namespacePrefix, namespaceContext.getURI(namespacePrefix));
            }
        }
        // Re-add standard "xml" prefix mapping
        // TODO: WHY?
        namespaces.put(XMLConstants.XML_PREFIX, XMLConstants.XML_URI);
        metadata.addNamespaceMapping(rewriteId(rawId), namespaces);
    }

    final private void putMark(String rawId) {
        metadata.putMark(templateSAXStore.getMark(rewriteId(rawId)));
    }

    protected String rewriteId(String id) {
        return id;
    }

    private void storeXBLBinding(String elementAttribute) {
        elementAttribute = elementAttribute.replace('|', ':');
        final String bindingPrefix = XMLUtils.prefixFromQName(elementAttribute);
        if (bindingPrefix != null) {
            final String bindingURI = namespaceContext.getURI(bindingPrefix);
            if (bindingURI != null) {
                // Found URI
                metadata.storeXBLBinding(bindingURI, XMLUtils.localNameFromQName(elementAttribute));
            }
        }
    }

    @Override
    public void startPrefixMapping(String prefix, String uri) throws SAXException {
        namespaceContext.startPrefixMapping(prefix, uri);
        startPrefixMapping2(prefix, uri);
    }

    private Attributes getAttributesGatherNamespaces(String uriForDebug, String qNameForDebug, Attributes attributes, String[] newIdAttribute, final int idIndex) {
        final String rawId;
        if (isGenerateIds) {
            // Process ids
            if (idIndex == -1) {
                // Create a new "id" attribute, prefixing if needed
                final AttributesImpl newAttributes = new AttributesImpl(attributes);
                rawId = metadata.idGenerator().nextId();
                newAttributes.addAttribute("", "id", "id", XMLReceiverHelper.CDATA, rawId);
                attributes = newAttributes;
            } else {
                // Keep existing id
                rawId = attributes.getValue(idIndex);

                // Check for duplicate ids
                // See https://github.com/orbeon/orbeon-forms/issues/1892
                // TODO: create Element to provide more location info?
                if (topLevel && metadata.idGenerator().contains(rawId))
                    throw new ValidationException("Duplicate id for XForms element: " + rawId,
                        new ExtendedLocationData(LocationData.createIfPresent(documentLocator()), "analyzing control element",
                                new String[] { "element", SAXUtils.saxElementToDebugString(uriForDebug, qNameForDebug, attributes), "id", rawId }));
            }

        } else {
            // Don't create a new id but remember the existing one
            rawId = attributes.getValue(idIndex);
        }

        // Remember that this id was used
        if (rawId != null) {
            metadata.idGenerator().add(rawId);

            // Gather namespace information if there is an id
            if (isGenerateIds || idIndex != -1) {
                addNamespaceMapping(rawId);
            }
        }

        newIdAttribute[0] = rawId;

        return attributes;
    }
}
