/**
 * Copyright (c) 2006, www.jempbox.org
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 * 3. Neither the name of pdfbox; nor the names of its
 *    contributors may be used to endorse or promote products derived from this
 *    software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * http://www.jempbox.org
 *
 */
package org.jempbox.xmp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import javax.xml.transform.TransformerException;

import org.jempbox.impl.XMLUtil;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.ProcessingInstruction;
import org.xml.sax.InputSource;

/**
 * This class represents the top level XMP data structure and gives access to
 * the various schemas that are available as part of the XMP specification.
 * 
 * @author <a href="mailto:ben@benlitchfield.com">Ben Litchfield</a>
 * @version $Revision: 1.10 $
 */
public class XMPMetadata
{
    /**
     * Supported encoding for persisted XML.
     */
    public static final String ENCODING_UTF8 = "UTF-8";

    /**
     * Supported encoding for persisted XML.
     */
    public static final String ENCODING_UTF16BE = "UTF-16BE";

    /**
     * Supported encoding for persisted XML.
     */
    public static final String ENCODING_UTF16LE = "UTF-16LE";

    /**
     * The DOM representation of the metadata.
     */
    protected Document xmpDocument;

    /**
     * The encoding of the XMP document. Default is UTF8.
     */
    protected String encoding = ENCODING_UTF8;

    /**
     * A mapping of namespaces.
     */
    protected Map nsMappings = new HashMap();

    /**
     * Default constructor, creates blank XMP doc.
     * 
     * @throws IOException
     *             If there is an error creating the initial document.
     */
    public XMPMetadata() throws IOException
    {
        xmpDocument = XMLUtil.newDocument();
        ProcessingInstruction beginXPacket = xmpDocument
                .createProcessingInstruction("xpacket",
                        "begin=\"\uFEFF\" id=\"W5M0MpCehiHzreSzNTczkc9d\"");

        xmpDocument.appendChild(beginXPacket);
        Element xmpMeta = xmpDocument.createElementNS("adobe:ns:meta/",
                "x:xmpmeta");
        xmpMeta.setAttributeNS(XMPSchema.NS_NAMESPACE, "xmlns:x",
                "adobe:ns:meta/");

        xmpDocument.appendChild(xmpMeta);

        Element rdf = xmpDocument.createElement("rdf:RDF");
        rdf.setAttributeNS(XMPSchema.NS_NAMESPACE, "xmlns:rdf",
                "http://www.w3.org/1999/02/22-rdf-syntax-ns#");

        xmpMeta.appendChild(rdf);

        ProcessingInstruction endXPacket = xmpDocument
                .createProcessingInstruction("xpacket", "end=\"w\"");
        xmpDocument.appendChild(endXPacket);
        init();
    }

    /**
     * Constructor from an existing XML document.
     * 
     * @param doc
     *            The root XMP document.
     */
    public XMPMetadata(Document doc)
    {
        xmpDocument = doc;
        init();
    }

    private void init()
    {
        nsMappings.put(XMPSchemaPDF.NAMESPACE, XMPSchemaPDF.class);
        nsMappings.put(XMPSchemaBasic.NAMESPACE, XMPSchemaBasic.class);
        nsMappings
                .put(XMPSchemaDublinCore.NAMESPACE, XMPSchemaDublinCore.class);
        nsMappings.put(XMPSchemaMediaManagement.NAMESPACE,
                XMPSchemaMediaManagement.class);
        nsMappings.put(XMPSchemaRightsManagement.NAMESPACE,
                XMPSchemaRightsManagement.class);
        nsMappings.put(XMPSchemaBasicJobTicket.NAMESPACE,
                XMPSchemaBasicJobTicket.class);
        nsMappings.put(XMPSchemaDynamicMedia.NAMESPACE,
                XMPSchemaDynamicMedia.class);
        nsMappings.put(XMPSchemaPagedText.NAMESPACE, XMPSchemaPagedText.class);
        nsMappings.put(XMPSchemaIptc4xmpCore.NAMESPACE,
                XMPSchemaIptc4xmpCore.class);
        nsMappings.put(XMPSchemaPhotoshop.NAMESPACE, XMPSchemaPhotoshop.class);
    }

    /**
     * Will add a XMPSchema to the set of identified schemas.
     * 
     * The class needs to have a constructor with parameter Element
     * 
     * @param namespace
     *            The namespace URI of the schmema for instance
     *            http://purl.org/dc/elements/1.1/.
     * @param xmpSchema
     *            The schema to associated this identifier with.
     */
    public void addXMLNSMapping(String namespace, Class xmpSchema)
    {

        if (!(XMPSchema.class.isAssignableFrom(xmpSchema)))
        {
            throw new IllegalArgumentException(
                    "Only XMPSchemas can be mapped to.");
        }

        nsMappings.put(namespace, xmpSchema);
    }

    /**
     * Get the PDF Schema.
     * 
     * @return The first PDF schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaPDF getPDFSchema() throws IOException
    {
        return (XMPSchemaPDF) getSchemaByClass(XMPSchemaPDF.class);
    }

    /**
     * Get the Basic Schema.
     * 
     * @return The first Basic schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaBasic getBasicSchema() throws IOException
    {
        return (XMPSchemaBasic) getSchemaByClass(XMPSchemaBasic.class);
    }

    /**
     * Get the Dublin Core Schema.
     * 
     * @return The first Dublin schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaDublinCore getDublinCoreSchema() throws IOException
    {
        return (XMPSchemaDublinCore) getSchemaByClass(XMPSchemaDublinCore.class);
    }

    /**
     * Get the Media Management Schema.
     * 
     * @return The first Media Management schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaMediaManagement getMediaManagementSchema()
            throws IOException
    {
        return (XMPSchemaMediaManagement) getSchemaByClass(XMPSchemaMediaManagement.class);
    }

    /**
     * Get the Schema Rights Schema.
     * 
     * @return The first Schema Rights schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaRightsManagement getRightsManagementSchema()
            throws IOException
    {
        return (XMPSchemaRightsManagement) getSchemaByClass(XMPSchemaRightsManagement.class);
    }

    /**
     * Get the Job Ticket Schema.
     * 
     * @return The first Job Ticket schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaBasicJobTicket getBasicJobTicketSchema() throws IOException
    {
        return (XMPSchemaBasicJobTicket) getSchemaByClass(XMPSchemaBasicJobTicket.class);
    }

    /**
     * Get the Dynamic Media Schema.
     * 
     * @return The first Dynamic Media schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaDynamicMedia getDynamicMediaSchema() throws IOException
    {
        return (XMPSchemaDynamicMedia) getSchemaByClass(XMPSchemaDynamicMedia.class);
    }

    /**
     * Get the Paged Text Schema.
     * 
     * @return The first Paged Text schema in the list.
     * 
     * @throws IOException
     *             If there is an error accessing the schema.
     */
    public XMPSchemaPagedText getPagedTextSchema() throws IOException
    {
        return (XMPSchemaPagedText) getSchemaByClass(XMPSchemaPagedText.class);
    }

    /**
     * Add a new Media Management schema.
     * 
     * @return The newly added schema.
     */
    public XMPSchemaMediaManagement addMediaManagementSchema()
    {
        XMPSchemaMediaManagement schema = new XMPSchemaMediaManagement(this);
        return (XMPSchemaMediaManagement) basicAddSchema(schema);
    }

    /**
     * Add a new Rights Managment schema.
     * 
     * @return The newly added schema.
     */
    public XMPSchemaRightsManagement addRightsManagementSchema()
    {
        XMPSchemaRightsManagement schema = new XMPSchemaRightsManagement(this);
        return (XMPSchemaRightsManagement) basicAddSchema(schema);
    }

    /**
     * Add a new Job Ticket schema.
     * 
     * @return The newly added schema.
     */
    public XMPSchemaBasicJobTicket addBasicJobTicketSchema()
    {
        XMPSchemaBasicJobTicket schema = new XMPSchemaBasicJobTicket(this);
        return (XMPSchemaBasicJobTicket) basicAddSchema(schema);
    }

    /**
     * Add a new Dynamic Media schema.
     * 
     * @return The newly added schema.
     */
    public XMPSchemaDynamicMedia addDynamicMediaSchema()
    {
        XMPSchemaDynamicMedia schema = new XMPSchemaDynamicMedia(this);
        return (XMPSchemaDynamicMedia) basicAddSchema(schema);
    }

    /**
     * Add a new Paged Text schema.
     * 
     * @return The newly added schema.
     */
    public XMPSchemaPagedText addPagedTextSchema()
    {
        XMPSchemaPagedText schema = new XMPSchemaPagedText(this);
        return (XMPSchemaPagedText) basicAddSchema(schema);
    }

    /**
     * Add a custom schema to the root rdf. The schema has to have been created
     * as a child of this XMPMetadata.
     * 
     * @param schema
     *            The schema to add.
     */
    public void addSchema(XMPSchema schema)
    {
        Element rdf = getRDFElement();
        rdf.appendChild(schema.getElement());
    }

    /**
     * Save the XMP document to a file.
     * 
     * @param file
     *            The file to save the XMP document to.
     * 
     * @throws Exception
     *             If there is an error while writing to the stream.
     */
    public void save(String file) throws Exception
    {
        XMLUtil.save(xmpDocument, file, encoding);
    }

    /**
     * Save the XMP document to a stream.
     * 
     * @param outStream
     *            The stream to save the XMP document to.
     * 
     * @throws TransformerException
     *             If there is an error while writing to the stream.
     */
    public void save(OutputStream outStream) throws TransformerException
    {
        XMLUtil.save(xmpDocument, outStream, encoding);
    }

    /**
     * Get the XML document as a byte array.
     * 
     * @return The metadata as an XML byte stream.
     * @throws Exception
     *             If there is an error creating the stream.
     */
    public byte[] asByteArray() throws Exception
    {
        return XMLUtil.asByteArray(xmpDocument, encoding);
    }

    /**
     * Get the XML document from this object.
     * 
     * @return This object as an XML document.
     */
    public Document getXMPDocument()
    {
        return xmpDocument;
    }

    /**
     * Generic add schema method.
     * 
     * @param schema
     *            The schema to add.
     * 
     * @return The newly added schema.
     */
    protected XMPSchema basicAddSchema(XMPSchema schema)
    {
        Element rdf = getRDFElement();
        rdf.appendChild(schema.getElement());
        return schema;
    }

    /**
     * Create and add a new PDF Schema to this metadata. Typically a XMP
     * document will only have one PDF schema (but multiple are supported) so it
     * is recommended that you first check the existence of a PDF scheme by
     * using getPDFSchema()
     * 
     * @return A new blank PDF schema that is now part of the metadata.
     */
    public XMPSchemaPDF addPDFSchema()
    {
        XMPSchemaPDF schema = new XMPSchemaPDF(this);
        return (XMPSchemaPDF) basicAddSchema(schema);
    }

    /**
     * Create and add a new Dublin Core Schema to this metadata. Typically a XMP
     * document will only have one schema for each type (but multiple are
     * supported) so it is recommended that you first check the existence of a
     * this scheme by using getDublinCoreSchema()
     * 
     * @return A new blank PDF schema that is now part of the metadata.
     */
    public XMPSchemaDublinCore addDublinCoreSchema()
    {
        XMPSchemaDublinCore schema = new XMPSchemaDublinCore(this);
        return (XMPSchemaDublinCore) basicAddSchema(schema);
    }

    /**
     * Create and add a new Basic Schema to this metadata. Typically a XMP
     * document will only have one schema for each type (but multiple are
     * supported) so it is recommended that you first check the existence of a
     * this scheme by using getDublinCoreSchema()
     * 
     * @return A new blank PDF schema that is now part of the metadata.
     */
    public XMPSchemaBasic addBasicSchema()
    {
        XMPSchemaBasic schema = new XMPSchemaBasic(this);
        return (XMPSchemaBasic) basicAddSchema(schema);
    }

    /**
     * Create and add a new IPTC schema to this metadata.
     * 
     * @return A new blank IPTC schema that is now part of the metadata.
     */
    public XMPSchemaIptc4xmpCore addIptc4xmpCoreSchema()
    {
        XMPSchemaIptc4xmpCore schema = new XMPSchemaIptc4xmpCore(this);
        return (XMPSchemaIptc4xmpCore) basicAddSchema(schema);
    }

    /**
     * Create and add a new Photoshop schema to this metadata.
     * 
     * @return A new blank Photoshop schema that is now part of the metadata.
     */
    public XMPSchemaPhotoshop addPhotoshopSchema()
    {
        XMPSchemaPhotoshop schema = new XMPSchemaPhotoshop(this);
        return (XMPSchemaPhotoshop) basicAddSchema(schema);
    }

    /**
     * The encoding used to write the XML. Default value:UTF-8<br/> See the
     * ENCODING_XXX constants
     * 
     * @param xmlEncoding
     *            The encoding to write the XML as.
     */
    public void setEncoding(String xmlEncoding)
    {
        encoding = xmlEncoding;
    }

    /**
     * Get the current encoding that will be used to write the XML.
     * 
     * @return The current encoding to write the XML to.
     */
    public String getEncoding()
    {
        return encoding;
    }

    /**
     * Get the root RDF element.
     * 
     * @return The root RDF element.
     */
    private Element getRDFElement()
    {
        Element rdf = null;
        NodeList nodes = xmpDocument.getElementsByTagName("rdf:RDF");
        if (nodes.getLength() > 0)
        {
            rdf = (Element) nodes.item(0);
        }
        return rdf;
    }

    /**
     * Load metadata from the filesystem.
     * 
     * @param file
     *            The file to load the metadata from.
     * 
     * @return The loaded XMP document.
     * 
     * @throws IOException
     *             If there is an error reading the data.
     */
    public static XMPMetadata load(String file) throws IOException
    {
        return new XMPMetadata(XMLUtil.parse(file));
    }

    /**
     * Load a schema from an input source.
     * 
     * @param is
     *            The input source to load the schema from.
     * 
     * @return The loaded/parsed schema.
     * 
     * @throws IOException
     *             If there was an error while loading the schema.
     */
    public static XMPMetadata load(InputSource is) throws IOException
    {
        return new XMPMetadata(XMLUtil.parse(is));
    }

    /**
     * Load metadata from the filesystem.
     * 
     * @param is
     *            The stream to load the data from.
     * 
     * @return The loaded XMP document.
     * 
     * @throws IOException
     *             If there is an error reading the data.
     */
    public static XMPMetadata load(InputStream is) throws IOException
    {
        return new XMPMetadata(XMLUtil.parse(is));
    }

    /**
     * Test main program.
     * 
     * @param args
     *            The command line arguments.
     * @throws Exception
     *             If there is an error.
     */
    public static void main(String[] args) throws Exception
    {
        XMPMetadata metadata = new XMPMetadata();
        XMPSchemaPDF pdf = metadata.addPDFSchema();
        pdf.setAbout("uuid:b8659d3a-369e-11d9-b951-000393c97fd8");
        pdf.setKeywords("ben,bob,pdf");
        pdf.setPDFVersion("1.3");
        pdf.setProducer("Acrobat Distiller 6.0.1 for Macintosh");

        XMPSchemaDublinCore dc = metadata.addDublinCoreSchema();
        dc.addContributor("Ben Litchfield");
        dc.addContributor("Solar Eclipse");
        dc.addContributor("Some Other Guy");

        XMPSchemaBasic basic = metadata.addBasicSchema();
        Thumbnail t = new Thumbnail(metadata);
        t.setFormat(Thumbnail.FORMAT_JPEG);
        t.setImage("IMAGE_DATA");
        t.setHeight(new Integer(100));
        t.setWidth(new Integer(200));
        basic.setThumbnail(t);
        basic.setBaseURL("http://www.pdfbox.org/");

        List schemas = metadata.getSchemas();
        System.out.println("schemas=" + schemas);

        metadata.save("test.xmp");
    }

    /**
     * This will get a list of XMPSchema(or subclass) objects.
     * 
     * @return A non null read-only list of schemas that are part of this
     *         metadata.
     * 
     * @throws IOException
     *             If there is an error creating a specific schema.
     */
    public List getSchemas() throws IOException
    {
        NodeList schemaList = xmpDocument
                .getElementsByTagName("rdf:Description");
        List retval = new ArrayList(schemaList.getLength());
        for (int i = 0; i < schemaList.getLength(); i++)
        {
            Element schema = (Element) schemaList.item(i);
            boolean found = false;
            NamedNodeMap attributes = schema.getAttributes();
            for (int j = 0; j < attributes.getLength() && !found; j++)
            {
                Node attribute = attributes.item(j);
                String name = attribute.getNodeName();
                String value = attribute.getNodeValue();
                if (name.startsWith("xmlns:") && nsMappings.containsKey(value))
                {
                    Class schemaClass = (Class) nsMappings.get(value);
                    try
                    {
                        Constructor ctor = schemaClass
                                .getConstructor(new Class[] { Element.class,
                                        String.class });
                        retval.add(ctor.newInstance(new Object[] { schema,
                                name.substring(6) }));
                        found = true;
                    }
                    catch(NoSuchMethodException e)
                    {
                        throw new IOException(
                                "Error: Class "
                                        + schemaClass.getName()
                                        + " must have a constructor with the signature of "
                                        + schemaClass.getName()
                                        + "( org.w3c.dom.Element, java.lang.String )");
                    }
                    catch(Exception e)
                    {
                        e.printStackTrace();
                        throw new IOException(e.getMessage());
                    }
                }
            }
            if (!found)
            {
                retval.add(new XMPSchema(schema, null));
            }
        }
        return retval;
    }

    /**
     * Will return all schemas that fit the given namespaceURI. Which is only
     * done by using the namespace mapping (nsMapping) and not by actually
     * checking the xmlns property.
     * 
     * @param namespaceURI
     *            The namespaceURI to filter for.
     * @return A list containing the found schemas or an empty list if non are
     *         found or the namespaceURI could not be found in the namespace
     *         mapping.
     * @throws IOException
     *             If an operation on the document fails.
     */
    public List getSchemasByNamespaceURI(String namespaceURI)
            throws IOException
    {

        List l = getSchemas();
        List result = new LinkedList();

        Class schemaClass = (Class) nsMappings.get(namespaceURI);
        if (schemaClass == null)
        {
            return result;
        }

        Iterator i = l.iterator();
        while (i.hasNext())
        {
            XMPSchema schema = (XMPSchema) i.next();

            if (schemaClass.isAssignableFrom(schema.getClass()))
            {
                result.add(schema);
            }
        }
        return result;
    }

    /**
     * This will return true if the XMP contains an unknown schema.
     * 
     * @return True if an unknown schema is found, false otherwise
     * 
     * @throws IOException
     *             If there is an error
     */
    public boolean hasUnknownSchema() throws IOException
    {
        NodeList schemaList = xmpDocument
                .getElementsByTagName("rdf:Description");
        for (int i = 0; i < schemaList.getLength(); i++)
        {
            Element schema = (Element) schemaList.item(i);
            NamedNodeMap attributes = schema.getAttributes();
            for (int j = 0; j < attributes.getLength(); j++)
            {
                Node attribute = attributes.item(j);
                String name = attribute.getNodeName();
                String value = attribute.getNodeValue();
                if (name.startsWith("xmlns:") && !nsMappings.containsKey(value)
                        && !value.equals(ResourceEvent.NAMESPACE))
                {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Tries to retrieve a schema from this by classname.
     * 
     * @param targetSchema
     *            Class for targetSchema.
     * 
     * @return XMPSchema or null if no target is found.
     * 
     * @throws IOException
     *             if there was an error creating the schemas of this.
     */
    public XMPSchema getSchemaByClass(Class targetSchema) throws IOException
    {
        Iterator iter = getSchemas().iterator();
        while (iter.hasNext())
        {
            XMPSchema element = (XMPSchema) iter.next();
            if (element.getClass().getName().equals(targetSchema.getName()))
            {
                return element;
            }
        }
        // not found
        return null;
    }

    /**
     * Merge this metadata with the given metadata object.
     * 
     * @param metadata The metadata to merge with this document.
     * 
     * @throws IOException If there is an error merging the data.
     */
    public void merge(XMPMetadata metadata) throws IOException
    {
        List schemas2 = metadata.getSchemas();
        for (Iterator iterator = schemas2.iterator(); iterator.hasNext();)
        {
            XMPSchema schema2 = (XMPSchema) iterator.next();
            XMPSchema schema1 = getSchemaByClass(schema2.getClass());
            if (schema1 == null)
            {
                Element rdf = getRDFElement();
                rdf.appendChild(xmpDocument.importNode(schema2.getElement(),
                        true));
            }
            else
            {
                schema1.merge(schema2);
            }
        }
    }
}
