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

import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Logger;
import org.dom4j.Document;
import org.dom4j.Element;
import org.orbeon.oxf.common.OXFException;
import org.orbeon.oxf.pipeline.api.ExternalContext;
import org.orbeon.oxf.pipeline.api.PipelineContext;
import org.orbeon.oxf.xml.XMLReceiver;
import org.orbeon.oxf.processor.CacheableInputReader;
import org.orbeon.oxf.processor.Datasource;
import org.orbeon.oxf.processor.ProcessorImpl;
import org.orbeon.oxf.processor.ProcessorInput;
import org.orbeon.oxf.util.LoggerFactory;
import org.orbeon.oxf.util.NetUtils;
import org.orbeon.oxf.xml.*;
import org.orbeon.oxf.xml.dom4j.Dom4jUtils;
import org.orbeon.oxf.xml.dom4j.LocationSAXContentHandler;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;
import org.xmldb.api.DatabaseManager;
import org.xmldb.api.base.*;
import org.xmldb.api.modules.*;

import java.io.ByteArrayInputStream;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;

/**
 * This is the main XML:DB processor. It should be able to access all databases supporting the XML:DB API. It
 * implements convenience methods for derived processors.
 *
 * See http://www.xmldb.org/xapi/xapi-draft.html for more information about XML:DB.
 *
 * See also the eXist Javadocs at http://exist.sourceforge.net/api/index.html.
 */
public abstract class XMLDBProcessor extends ProcessorImpl {

    static Logger logger = LoggerFactory.createLogger(XMLDBProcessor.class);

    public static final String INPUT_DATASOURCE = "datasource";
    public static final String INPUT_QUERY = "query";

    public static final String XMLDB_DATASOURCE_URI = "http://www.orbeon.org/oxf/xmldb-datasource";
    public static final String XMLDB_QUERY_URI = "http://www.orbeon.org/oxf/xmldb-query";

    protected static final String ROOT_COLLECTION_PATH = "/db";
    protected static final String XMLDB_URI_PREFIX = "xmldb:";
    protected static final String XUPDATE_SERVICE_NAME = "XUpdateQueryService";
    protected static final String XPATH_SERVICE_NAME = "XPathQueryService";
    protected static final String COLLECTION_SERVICE_NAME = "CollectionManagementService";

    private static Map<String, Database> drivers = new HashMap<String, Database>();

    private Config readConfig(Document configDocument) {
        Config config = new Config();

        Element rootElement = configDocument.getRootElement();
        config.setOperation(rootElement.getName());
        config.setCollection(rootElement.attributeValue("collection"));
        config.setCreateCollection(rootElement.attributeValue("create-collection"));
        config.setResourceId(rootElement.attributeValue("resource-id"));

        config.setQuery(Dom4jUtils.objectToString(XPathUtils.selectObjectValue(configDocument, "/*/text() | /*/*")));

        final Map<String, String> namespaceContext = Dom4jUtils.getNamespaceContext(configDocument.getRootElement());
        // Not sure why 1) xml needs to be in there and 2) why eXist balks on it, but we remove it here for eXist
        namespaceContext.remove(XMLConstants.XML_PREFIX);
        config.setNamespaceContext(namespaceContext);

        return config;
    }

    protected Datasource getDatasource(PipelineContext pipelineContext) {
        return Datasource.getDatasource(pipelineContext, this, getInputByName(INPUT_DATASOURCE));
    }

    protected Config getConfig(PipelineContext pipelineContext) {
         return readCacheInputAsObject(pipelineContext, getInputByName(INPUT_QUERY), new CacheableInputReader<Config>() {
            public Config read(PipelineContext context, ProcessorInput input) {
                // Use readInputAsSAX so that we can filter namespaces if needed
                LocationSAXContentHandler ch = new LocationSAXContentHandler();
                readInputAsSAX(context, input, new NamespaceCleanupXMLReceiver(ch, isSerializeXML11()));
                return readConfig(ch.getDocument());
            }
        });
    }

    protected synchronized static void ensureDriverRegistered(Datasource datasource) {
        String driverClassName = datasource.getDriverClassName();
        if (drivers.get(driverClassName) == null) {
            // Initialize database driver
            try {
                Class cl = Class.forName(driverClassName);
                Database database = (Database) cl.newInstance();
                DatabaseManager.registerDatabase(database);
                {
                    // This is specific for eXist
                    // TODO: move this to properties?
                    ExternalContext externalContext = NetUtils.getExternalContext();
                    String configurationFile = externalContext.getWebAppContext().getRealPath("WEB-INF/exist-conf.xml");
                    database.setProperty("create-database", "true");
                    database.setProperty("configuration", configurationFile);
                }
                drivers.put(driverClassName, database);
            } catch (Exception e) {
                throw new OXFException("Cannot register XML:DB driver for class name: " + datasource.getDriverClassName(), e);
            }
        }
    }

    /*
     * Examples of datasourceURI / collection / XML:DB collection name combinations:
     *
     * 1. xmldb:exist:///
     *    /db/orbeon/bizdoc-example
     *    xmldb:exist:///db/orbeon/bizdoc-example
     * 2. xmldb:exist://localhost:9999/exist/xmlrpc
     *    /db/orbeon/bizdoc-example
     *    xmldb:exist://localhost:9999/exist/xmlrpc/db/orbeon/bizdoc-example
     */
    protected Collection getCollection(Datasource datasource, String collection) {
        ensureDriverRegistered(datasource);
        try {
            String datasourceURI = datasource.getUri();
            if (!datasourceURI.startsWith(XMLDB_URI_PREFIX))
                throw new OXFException("Invalid XML:DB URI: " + datasourceURI);
            if (!collection.startsWith("/"))
                throw new OXFException("Collection name must start with a '/': " + collection);

            // This makes sure that we have a correct URI syntax
            URI uri = new URI(datasourceURI.substring(XMLDB_URI_PREFIX.length()));

            // Rebuild a URI string
            String xmldbCollectionName = XMLDB_URI_PREFIX + uri.getScheme() + "://"
                    + (uri.getAuthority() == null ? "" : uri.getAuthority())
                    + (uri.getPath() == null ? "" : uri.getPath());
            if (xmldbCollectionName.endsWith("/"))
                xmldbCollectionName = xmldbCollectionName.substring(0, xmldbCollectionName.length() - 1);
            xmldbCollectionName = xmldbCollectionName + collection;

            return DatabaseManager.getCollection(xmldbCollectionName, datasource.getUsername(), datasource.getPassword());
        } catch (Exception e) {
            throw new OXFException(e);
        }
    }

    /**
     * Query resources from the database.
     *
     * @param datasource        the processor configuration
     * @param collectionName    identifies the collection in which resources are searched
     * @param createCollection  if true, create collection if it doesn't exist
     * @param resourceId        optional resource id on which the query is run
     * @param query             selects resources in the collection that must be searched
     * @param namespaceContext  namespace mappings
     * @param xmlReceiver       receiver where the resources are output
     */
    protected void query(Datasource datasource, String collectionName, boolean createCollection,
                         String resourceId, String query, Map<String, String> namespaceContext, XMLReceiver xmlReceiver) {
        ensureDriverRegistered(datasource);
        try {
            // Execute query
            ResourceSet result = executeQuery(datasource, collectionName, createCollection, resourceId, query, namespaceContext);

            // Output resources
            for (ResourceIterator i = result.getIterator(); i.hasMoreResources();) {
                Resource resource = i.nextResource();
                if (resource instanceof XMLResource) {
                    ((XMLResource) resource).getContentAsSAX(new DatabaseReadXMLReceiver(xmlReceiver));
                } else if (resource instanceof BinaryResource) {
                    SAXUtils.inputStreamToBase64Characters(new ByteArrayInputStream((byte[]) resource.getContent()), xmlReceiver);
                } else {
                    throw new OXFException("Unsupported resource type: " + resource.getClass());
                }
            }
        } catch (XMLDBException e) {
            throw new OXFException(e);
        }
    }

    protected void storeResource(Datasource datasource, String collectionName, boolean createCollection,
                                 String resourceName, String document) {
        ensureDriverRegistered(datasource);
        try {
            Collection collection = getCollection(datasource, collectionName);
            if (collection == null) {
                if (!createCollection)
                    throw new OXFException("Cannot find collection '" + collectionName + "'.");
                else
                    collection = createCollection(datasource, collectionName);
            }
            final Resource resource = collection.createResource(resourceName, XMLResource.RESOURCE_TYPE);
            resource.setContent(document);
            collection.storeResource(resource);

        } catch (XMLDBException e) {
            throw new OXFException(e);
        }
    }

    private ResourceSet executeQuery(Datasource datasource, String collectionName, boolean createCollection,
                                     String resourceId, String query, Map<String, String> namespaceContext) throws XMLDBException {

        Collection collection = getCollection(datasource, collectionName);
        if (collection == null) {
            if (!createCollection)
                throw new OXFException("Cannot find collection '" + collectionName + "'.");
            else
                collection = createCollection(datasource, collectionName);
        }
        final XPathQueryService xpathQueryService;
        try {
            // For eXist, this is the same as XQueryService
            xpathQueryService = (XPathQueryService) collection.getService(XPATH_SERVICE_NAME, "1.0");
        } catch (XMLDBException e) {
            if (e.errorCode == ErrorCodes.NO_SUCH_SERVICE)
                throw new OXFException("XML:DB " + XPATH_SERVICE_NAME + " does not exist.", e);
            else
                throw e;
        }
        if (xpathQueryService == null)
            throw new OXFException("XML:DB " + XPATH_SERVICE_NAME + " does not exist.");

        // Configure service (this is particular for eXist)
        // TODO: Should be configurable, but with what mechanism? 
        try {
            xpathQueryService.setProperty("highlight-matches", "no");
        } catch (Exception e) {
            logger.debug("Unable to set eXist highlight-matches", e);
        }

        // Set namespaces
        if (namespaceContext != null) {
            for (final String prefix: namespaceContext.keySet()) {
                xpathQueryService.setNamespace(prefix, namespaceContext.get(prefix));
            }
        }

        // Log for debug
        logger.debug(query);

        // Execute query
        final ResourceSet result;
        if (resourceId == null)
            result = xpathQueryService.query(query);
        else
            result = xpathQueryService.queryResource(resourceId, query);

        return result;
    }

    /**
     * Insert a resource in a collection.
     *
     * @param pipelineContext   current context
     * @param datasource        the processor configuration
     * @param collectionName    identifies the collection in which to insert the resource
     * @param createCollection  if true, create collection if it doesn't exist
     * @param resourceId        id of the new resource
     * @param input             processor input containing the XML resource to insert
     */
    protected void insert(PipelineContext pipelineContext, Datasource datasource, String collectionName, boolean createCollection,
                          String resourceId, ProcessorInput input) {
        ensureDriverRegistered(datasource);
        try {
            Collection collection = getCollection(datasource, collectionName);
            if (collection == null) {
                if (!createCollection)
                    throw new OXFException("Cannot find collection '" + collectionName + "'.");
                else
                    collection = createCollection(datasource, collectionName);
            }
            // Create new XMLResource
            XMLResource xmlResource = (XMLResource) collection.createResource(resourceId, "XMLResource");

            // Write to the resource
            // NOTE: Writing comments is not supported yet
            ContentHandler contentHandler = xmlResource.setContentAsSAX();
            readInputAsSAX(pipelineContext, input, new NamespaceCleanupXMLReceiver(contentHandler, isSerializeXML11()));

            // Store resource
            collection.storeResource(xmlResource);
        } catch (XMLDBException e) {
            throw new OXFException(e);
        }
    }

    private boolean isSerializeXML11() {
        return getPropertySet().getBoolean("serialize-xml-11", false);
    }

    protected Collection createCollection(Datasource datasource, String collectionName) throws XMLDBException {
        Collection rootCollection = getCollection(datasource, ROOT_COLLECTION_PATH);
        if (rootCollection == null)
            throw new OXFException("Cannot find root collection '" + ROOT_COLLECTION_PATH + "'.");

        CollectionManagementService mgtService = (CollectionManagementService)
                rootCollection.getService(COLLECTION_SERVICE_NAME, "1.0");

        if (!collectionName.startsWith(ROOT_COLLECTION_PATH + "/"))
            throw new OXFException("Collection name must start with '" + ROOT_COLLECTION_PATH + "': " + collectionName);

        return mgtService.createCollection(collectionName.substring(ROOT_COLLECTION_PATH.length() + 1));
    }

    /**
     * Update resources in the database.
     *
     * @param datasource        the processor configuration
     * @param collectionName    identifies the collection in which resources are updated
     * @param createCollection  if true, create collection if it doesn't exist
     * @param resourceId        optional resource id on which the query is run
     * @param query             the XUpdate query to run
     */
    protected void update(Datasource datasource, String collectionName, boolean createCollection,
                          String resourceId, String query) {
        ensureDriverRegistered(datasource);
        try {
            Collection collection = getCollection(datasource, collectionName);
            if (collection == null) {
                if (!createCollection)
                    throw new OXFException("Cannot find collection '" + collectionName + "'.");
                else
                    collection = createCollection(datasource, collectionName);
            }
            XUpdateQueryService xUpdateQueryService;
            try {
                xUpdateQueryService = (XUpdateQueryService) collection.getService(XUPDATE_SERVICE_NAME, "1.0");
            } catch (XMLDBException e) {
                if (e.errorCode == ErrorCodes.NO_SUCH_SERVICE)
                    throw new OXFException("XML:DB " + XUPDATE_SERVICE_NAME + " does not exist.", e);
                else
                    throw e;
            }
            if (xUpdateQueryService == null)
                throw new OXFException("XML:DB " + XUPDATE_SERVICE_NAME + " does not exist.");

            // Update either all the resources in a collection, or a specific resource
            if (resourceId == null)
                xUpdateQueryService.update(query);
            else
                xUpdateQueryService.updateResource(resourceId, query);
        } catch (XMLDBException e) {
            throw new OXFException(e);
        }
    }

    /**
     * Delete resources from the database.
     *
     * @param datasource        the processor configuration
     * @param collectionName    identifies the collection in which resources are searched
     * @param createCollection  if true, create collection if it doesn't exist
     * @param resourceId        optional resource id on which the query is run
     * @param query             selects resources in the collection that must be deleted
     * @param namespaceContext  namespace mappings
     */
    protected void delete(Datasource datasource, String collectionName, boolean createCollection,
                          String resourceId, String query, Map<String, String> namespaceContext) {
        ensureDriverRegistered(datasource);
        try {
            // Execute query
            final ResourceSet result = executeQuery(datasource, collectionName, createCollection, resourceId, query, namespaceContext);

            if (result.getSize() > 0) {
                // Delete resources

                // NOTE: As of 2009-10-27, with eXist 1.2.5, the following doesn't work:
                //
                // resource.getParentCollection().removeResource(resource)
                //
                // So we implement a workaround: we go up to the resource from the root collection.

                final Collection rootCollection = getCollection(datasource, ROOT_COLLECTION_PATH);
                for (final ResourceIterator i = result.getIterator(); i.hasMoreResources();) {
                    final Resource resource = i.nextResource();

                    Collection parentCollection;
                    {
                        parentCollection = rootCollection;
                        final String[] subCollections = StringUtils.split(resource.getParentCollection().getName().substring(ROOT_COLLECTION_PATH.length()), '/');
                        for (final String subCollection: subCollections) {
                            parentCollection = parentCollection.getChildCollection(subCollection);
                        }
                    }

                    parentCollection.removeResource(resource);
                }
            }
        } catch (XMLDBException e) {
            throw new OXFException(e);
        }
    }

     protected void executeOperation(PipelineContext pipelineContext, XMLReceiver xmlReceiver) {
        // Get datasource and configuration
        final Datasource datasource = getDatasource(pipelineContext);
        final Config config = getConfig(pipelineContext);

        if ("query".equals(config.getOperation())) {
            query(datasource, config.getCollection(), "true".equals(config.getCreateCollection()), config.getResourceId(), config.getQuery(), config.getNamespaceContext(), xmlReceiver);
        } else if ("insert".equals(config.getOperation())) {
            insert(pipelineContext, datasource, config.getCollection(), "true".equals(config.getCreateCollection()), config.getResourceId(), getInputByName(INPUT_DATA));
        } else if ("delete".equals(config.getOperation())) {
            delete(datasource, config.getCollection(), "true".equals(config.getCreateCollection()), config.getResourceId(), config.getQuery(), config.getNamespaceContext());
        } else if ("update".equals(config.getOperation())) {
            update(datasource, config.getCollection(), "true".equals(config.getCreateCollection()), config.getResourceId(), config.getQuery());
        } else {
            // TODO: Handle location info
            throw new IllegalArgumentException("Invalid operation: " + config.getOperation());
        }
    }

    protected static class Config {
        private String operation;
        private String collection;
        private String createCollection;
        private String resourceId;
        private String query;
        private Map<String, String> namespaceContext;

        public String getOperation() {
            return operation;
        }

        public void setOperation(String operation) {
            this.operation = operation;
        }

        public String getCollection() {
            return collection;
        }

        public void setCollection(String collection) {
            this.collection = collection;
        }

        public String getCreateCollection() {
            return createCollection;
        }

        public void setCreateCollection(String createCollection) {
            this.createCollection = createCollection;
        }

        public String getResourceId() {
            return resourceId;
        }

        public void setResourceId(String resourceId) {
            this.resourceId = resourceId;
        }

        public String getQuery() {
            return query;
        }

        public void setQuery(String query) {
            this.query = query;
        }

        public Map<String, String> getNamespaceContext() {
            return namespaceContext;
        }

        public void setNamespaceContext(Map<String, String> namespaceContext) {
            this.namespaceContext = namespaceContext;
        }
    }

    /**
     * Clean-up the SAX output. Some databases, such as eXist, output incorrect SAX that causes issues down the line.
     */
    public static class DatabaseReadXMLReceiver extends ForwardingXMLReceiver {

        private int startDocumentLevel = 0;

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

        public void startDocument() throws SAXException {
            if (startDocumentLevel++ == 0)
                super.startDocument();
        }

        public void endDocument() throws SAXException {
            if (--startDocumentLevel == 0)
                super.endDocument();
        }
    }
}
