/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 * 
 * Copyright 2008 jOpenDocument, by ILM Informatique. All rights reserved.
 * 
 * The contents of this file are subject to the terms of the GNU
 * General Public License Version 3 only ("GPL").  
 * You may not use this file except in compliance with the License. 
 * You can obtain a copy of the License at http://www.gnu.org/licenses/gpl-3.0.html
 * See the License for the specific language governing permissions and limitations under the License.
 * 
 * When distributing the software, include this License Header Notice in each file.
 * 
 */

package org.jopendocument.dom;

import org.jopendocument.util.CopyUtils;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.Transformer;
import org.jdom.Attribute;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.xpath.XPath;

/**
 * An XML document containing all of an office document, see section 2.1 of OpenDocument 1.1.
 * 
 * @author Sylvain CUAZ 24 nov. 2004
 */
public class ODSingleXMLDocument extends ODXMLDocument implements Cloneable {

    final static Set<String> DONT_PREFIX;
    static {
        DONT_PREFIX = new HashSet<String>();
        // don't touch to user fields and variables
        // we want them to be the same across the document
        DONT_PREFIX.add("user-field-decl");
        DONT_PREFIX.add("user-field-get");
        DONT_PREFIX.add("variable-get");
        DONT_PREFIX.add("variable-decl");
        DONT_PREFIX.add("variable-set");
    }

    // Voir le TODO du ctor
    // public static OOSingleXMLDocument createEmpty() {
    // }

    /**
     * Create a document from a collection of subdocuments.
     * 
     * @param content the content.
     * @param style the styles, can be <code>null</code>.
     * @return the merged document.
     */
    public static ODSingleXMLDocument createFromDocument(Document content, Document style) {
        return createFromDocument(content, style, null);
    }

    public static ODSingleXMLDocument createFromDocument(Document content, Document style, Document settings) {
        return createFromDocument(content, style, settings, new ODPackage());
    }

    static ODSingleXMLDocument createFromDocument(Document content, Document style, Document settings, ODPackage files) {
        final ODSingleXMLDocument single = new ODSingleXMLDocument(content, files);
        if (single.getChild("body") == null)
            throw new IllegalArgumentException("no body in " + single);
        // signal that the xml is a complete document (was document-content)
        single.getDocument().getRootElement().setName("document");
        if (style != null) {
            // section 2.1 : Styles used in the document content and automatic styles used in the
            // styles themselves.
            // more precisely in section 2.1.1 : office:document-styles contains style, master
            // style, auto style, font decls ; the last two being also in content.xml but are *not*
            // related : eg P1 of styles.xml is *not* the P1 of content.xml
            try {
                single.mergeAllStyles(new ODXMLDocument(style), true);
            } catch (JDOMException e) {
                throw new IllegalArgumentException("style is not valid", e);
            }
        }
        if (settings != null) {
            final Element officeSettings = (Element) settings.getRootElement().getChildren().get(0);
            single.getDocument().getRootElement().addContent(0, (Element) officeSettings.clone());
        }
        return single;
    }

    /**
     * Create a document from a file.
     * 
     * @param f an OpenDocument package file.
     * @return the merged file.
     * @throws JDOMException if the file is not a valid OpenDocument file.
     * @throws IOException if the file can't be read.
     * @see #createFromDocument(Document, Document)
     */
    public static ODSingleXMLDocument createFromFile(File f) throws JDOMException, IOException {
        // this loads all linked files
        return new ODPackage(f).toSingle();
    }

    /**
     * fix bug when a SingleXMLDoc is used to create a document (for example with P2 and 1_P2), and
     * then create another instance s2 with the previous document and add a second file (also with
     * P2 and 1_P2) => s2 will contain P2, 1_P2, 1_P2, 1_1_P2.
     */
    private static final String COUNT = ODSingleXMLDocument.class.getName() + "_count";

    private static void setCount(Document doc, int count) {
        doc.getRootElement().setAttribute(COUNT, count + "");
    }

    private static boolean hasCount(Document doc) {
        return doc.getRootElement().getAttribute(COUNT) != null;
    }

    private static int getCount(Document doc) {
        return Integer.parseInt(doc.getRootElement().getAttributeValue(COUNT));
    }

    /** Le nombre de fichiers concat */
    private int numero;
    /** Les styles présent dans ce document */
    private final Set<String> stylesNames;
    /** Les styles de liste présent dans ce document */
    private final Set<String> listStylesNames;
    /** Les fichiers référencés par ce document */
    private final ODPackage pkg;

    public ODSingleXMLDocument(Document content) {
        this(content, new ODPackage());
    }

    /**
     * A new single document. NOTE: this document will put himself in <code>pkg</code>, replacing
     * any previous content.
     * 
     * @param content the XML.
     * @param pkg the package this document belongs to.
     */
    private ODSingleXMLDocument(Document content, final ODPackage pkg) {
        super(content);

        if (hasCount(this.getDocument())) {
            this.setNumero(getCount(this.getDocument()));
        } else {
            // if not hasCount(), it's not us that created content
            // so there should not be any 1_
            this.setNumero(0);
        }

        this.stylesNames = new HashSet<String>(64);
        this.listStylesNames = new HashSet<String>(16);

        // on remplit les styles utilisateurs
        // TODO faire un doc vide puis ajouter content, évite un cas spécial
        try {
            Iterator iter = this.getXPath("./style:style/@style:name").selectNodes(this.getChild("styles")).iterator();
            while (iter.hasNext()) {
                Attribute attr = (Attribute) iter.next();
                this.stylesNames.add(attr.getValue());
            }
            iter = this.getXPath("./style:style/@text:list-style").selectNodes(this.getChild("styles")).iterator();
            while (iter.hasNext()) {
                Attribute attr = (Attribute) iter.next();
                this.listStylesNames.add(attr.getValue());
            }
        } catch (JDOMException exn) {
            throw new IllegalArgumentException("can't find styles names.");
        }

        this.pkg = pkg;
        this.pkg.rmFile("styles.xml");
        this.pkg.rmFile("settings.xml");
        this.pkg.putFile("content.xml", this, "text/xml");
    }

    ODSingleXMLDocument(ODSingleXMLDocument doc, ODPackage p) {
        super(doc);
        this.setNumero(doc.numero);
        this.stylesNames = new HashSet<String>(doc.stylesNames);
        this.listStylesNames = new HashSet<String>(doc.listStylesNames);
        this.pkg = p;
    }

    @Override
    public ODSingleXMLDocument clone() {
        final ODPackage copy = new ODPackage(this.pkg);
        return (ODSingleXMLDocument) copy.getContent();
    }

    private void setNumero(int numero) {
        this.numero = numero;
        setCount(this.getDocument(), this.numero);
    }

    /**
     * Append a document.
     * 
     * @param doc the document to add.
     */
    public synchronized void add(ODSingleXMLDocument doc) {
        // ajoute un saut de page entre chaque document
        this.add(doc, true);
    }

    /**
     * Append a document.
     * 
     * @param doc the document to add, <code>null</code> means no-op.
     * @param pageBreak whether a page break should be inserted after <code>doc</code>.
     */
    public synchronized void add(ODSingleXMLDocument doc, boolean pageBreak) {
        // MAYBE faire l'ajout avant, mais attention => doc commence par une page vide
        if (this.add(null, 0, doc) && pageBreak)
            // only add a page break, if a page was really added
            this.getBody().addContent(this.getPageBreak());
    }

    public synchronized void replace(Element elem, ODSingleXMLDocument doc) {
        final Element parent = elem.getParentElement();
        this.add(parent, parent.indexOf(elem), doc);
        elem.detach();
    }

    public synchronized boolean add(Element where, int index, ODSingleXMLDocument doc) {
        if (doc == null)
            return false;
        if (!this.getVersion().equals(doc.getVersion()))
            throw new IllegalArgumentException("version mismatch");

        this.setNumero(this.numero + 1);
        try {
            this.mergeEmbedded(doc);
            this.mergeSettings(doc);
            this.mergeAllStyles(doc, false);
            this.mergeBody(where, index, doc);
        } catch (JDOMException exn) {
            throw new IllegalArgumentException("XML error", exn);
        }
        return true;
    }

    /**
     * Merge the four elements of style.
     * 
     * @param doc the xml document to merge.
     * @param sameDoc whether <code>doc</code> is the same OpenDocument than this, eg
     *        <code>true</code> when merging content.xml and styles.xml.
     * @throws JDOMException if an error occurs.
     */
    private void mergeAllStyles(ODXMLDocument doc, boolean sameDoc) throws JDOMException {
        this.mergeFontDecls(doc);
        this.mergeStyles(doc);
        this.mergeAutoStyles(doc);
        this.mergeMasterStyles(doc, !sameDoc);
    }

    private void mergeEmbedded(ODSingleXMLDocument doc) {
        // since we are adding another document our existing thumbnail is obsolete
        this.pkg.rmFile("Thumbnails/thumbnail.png");
        // copy the files
        final ODPackage opkg = CopyUtils.copy(doc.pkg);
        for (final String name : opkg.getEntries()) {
            final ODPackageEntry e = opkg.getEntry(name);
            if (!ODPackage.isStandardFile(e.getName())) {
                this.pkg.putFile(this.prefix(e.getName()), e.getData(), e.getType());
            }
        }
    }

    private void mergeSettings(ODSingleXMLDocument doc) throws JDOMException {
        this.addIfNotPresent(doc, "./office:settings", 0);
    }

    /**
     * Fusionne les office:font-decls/style:font-decl. On ne préfixe jamais, on ajoute seulement si
     * l'attribut style:name est différent.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @throws JDOMException
     */
    private void mergeFontDecls(ODXMLDocument doc) throws JDOMException {
        if (this.getNS().getVersion().equals(OOUtils.VERSION_1))
            this.mergeUnique(doc, "font-decls", "style:font-decl");
        else
            this.mergeUnique(doc, "font-face-decls", "style:font-face");
    }

    private void mergeStyles(ODXMLDocument doc) throws JDOMException {
        // les default-style (notamment tab-stop-distance)
        this.mergeUnique(doc, "styles", "style:default-style", "style:family", NOP_ElementTransformer);
        // les styles
        this.stylesNames.addAll(this.mergeUnique(doc, "styles", "style:style"));
        // on ajoute outline-style si non présent
        this.addStylesIfNotPresent(doc, "outline-style");
        // les list-style
        this.listStylesNames.addAll(this.mergeUnique(doc, "styles", "text:list-style"));
        // les *notes-configuration
        this.addStylesIfNotPresent(doc, "footnotes-configuration");
        this.addStylesIfNotPresent(doc, "endnotes-configuration");
    }

    /**
     * Fusionne les office:automatic-styles, on préfixe tout.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @throws JDOMException
     */
    private void mergeAutoStyles(ODXMLDocument doc) throws JDOMException {
        List<Element> addedStyles = this.prefixAndAddAutoStyles(doc);
        Iterator iter = this.getXPath("./@style:list-style-name").selectNodes(addedStyles).iterator();
        while (iter.hasNext()) {
            Attribute listStyleName = (Attribute) iter.next();
            // si la liste des styles non préfixés ne le contient pas, on le préfixe
            if (!this.listStylesNames.contains(listStyleName.getValue())) {
                listStyleName.setValue(this.prefix(listStyleName.getValue()));
            }
        }
    }

    /**
     * Fusionne les office:master-styles. On ne préfixe jamais, on ajoute seulement si l'attribut
     * style:name est différent.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @param ref whether to prefix hrefs.
     * @throws JDOMException if an error occurs.
     */
    private void mergeMasterStyles(ODXMLDocument doc, boolean ref) throws JDOMException {
        // est référencé dans les styles avec "style:master-page-name"
        this.mergeUnique(doc, "master-styles", "style:master-page", ref ? this.prefixTransf : this.prefixTransfNoRef);
    }

    /**
     * Fusionne les corps.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @throws JDOMException
     */
    private void mergeBody(Element where, int index, ODSingleXMLDocument doc) throws JDOMException {
        this.add(where, index, doc, this.getBodyPath(), new ElementTransformer() {
            public Element transform(Element elem) throws JDOMException {
                // ATTN n'ajoute pas sequence-decls
                if (elem.getName().equals("sequence-decls"))
                    return null;

                if (elem.getName().equals("user-field-decls")) {
                    // user fields are global to a document, they do not vary across it.
                    // hence they are initialized at declaration
                    // we should assure that there's no 2 declaration with the same name
                    detachDuplicate(elem);
                }

                if (elem.getName().equals("variable-decls")) {
                    // variables are not initialized at declaration
                    // we should still assure that there's no 2 declaration with the same name
                    detachDuplicate(elem);
                }

                // par défaut
                return ODSingleXMLDocument.this.prefixTransf.transform(elem);
            }
        });
    }

    /**
     * Detach the children of elem whose names already exist in the body.
     * 
     * @param elem the elem to be trimmed.
     * @throws JDOMException if an error occurs.
     */
    protected final void detachDuplicate(Element elem) throws JDOMException {
        final String singularName = elem.getName().substring(0, elem.getName().length() - 1);
        final List thisNames = getXPath("./text:" + singularName + "s/text:" + singularName + "/@text:name").selectNodes(getChild("body"));
        CollectionUtils.transform(thisNames, new Transformer() {
            public Object transform(Object obj) {
                return ((Attribute) obj).getValue();
            }
        });

        final Iterator iter = elem.getChildren().iterator();
        while (iter.hasNext()) {
            final Element decl = (Element) iter.next();
            if (thisNames.contains(decl.getAttributeValue("name", getNS().getTEXT()))) {
                // on retire les déjà existant
                iter.remove();
            }
        }
    }

    // *** Utils

    // returns the xpath to the body
    private final String getBodyPath() {
        return this.getNS().getVersion().equals(OOUtils.VERSION_1) ? "./office:body" : "./office:body/office:text";
    }

    public final Element getBody() {
        try {
            return this.getDescendant(this.getBodyPath());
        } catch (JDOMException e) {
            throw new IllegalStateException("no body", e);
        }
    }

    /**
     * Préfixe les attributs en ayant besoin.
     * 
     * @param elem l'élément à préfixer.
     * @param references whether to prefix hrefs.
     * @throws JDOMException if an error occurs.
     */
    void prefix(Element elem, boolean references) throws JDOMException {
        Iterator attrs = this.getXPath(".//@text:style-name | .//@table:style-name | .//@draw:style-name").selectNodes(elem).iterator();
        while (attrs.hasNext()) {
            Attribute attr = (Attribute) attrs.next();
            if (!this.listStylesNames.contains(attr.getValue()) && !this.stylesNames.contains(attr.getValue())) {
                attr.setValue(this.prefix(attr.getValue()));
            }
        }

        attrs = this.getXPath(".//@style:page-master-name | .//@style:page-layout-name | .//@text:name | .//@form:name | .//@form:property-name").selectNodes(elem).iterator();
        while (attrs.hasNext()) {
            final Attribute attr = (Attribute) attrs.next();
            final String parentName = attr.getParent().getName();
            if (!DONT_PREFIX.contains(parentName))
                attr.setValue(this.prefix(attr.getValue()));
        }

        // prefix references
        if (references) {
            attrs = this.getXPath(".//@xlink:href[../@xlink:show='embed']").selectNodes(elem).iterator();
            while (attrs.hasNext()) {
                final Attribute attr = (Attribute) attrs.next();
                final String prefixedPath = this.prefixPath(attr.getValue());
                if (prefixedPath != null)
                    attr.setValue(prefixedPath);
            }
        }
    }

    /**
     * Prefix a path.
     * 
     * @param href a path inside the pkg, eg "./Object 1/content.xml".
     * @return the prefixed path or <code>null</code> if href is external, eg "./3_Object
     *         1/content.xml".
     */
    private String prefixPath(final String href) {
        if (this.getVersion().equals(OOUtils.OOo)) {
            // in OOo 1.x inPKG is denoted by a #
            final boolean sharp = href.startsWith("#");
            if (sharp)
                // eg #Pictures/100000000000006C000000ABCC02339E.png
                return "#" + this.prefix(href.substring(1));
            else
                // eg ../../../../Program%20Files/OpenOffice.org1.1.5/share/gallery/apples.gif
                return null;
        } else {
            URI uri;
            try {
                uri = new URI(href);
            } catch (URISyntaxException e) {
                // OO doesn't escape characters for files
                uri = null;
            }
            // section 17.5
            final boolean inPKGFile = uri == null || uri.getScheme() == null && uri.getAuthority() == null && uri.getPath().charAt(0) != '/';
            if (inPKGFile) {
                final String dotSlash = "./";
                if (href.startsWith(dotSlash))
                    return dotSlash + this.prefix(href.substring(dotSlash.length()));
                else
                    return this.prefix(href);
            } else
                return null;
        }
    }

    private String prefix(String value) {
        return this.numero + "_" + value;
    }

    private final ElementTransformer prefixTransf = new ElementTransformer() {
        public Element transform(Element elem) throws JDOMException {
            ODSingleXMLDocument.this.prefix(elem, true);
            return elem;
        }
    };

    private final ElementTransformer prefixTransfNoRef = new ElementTransformer() {
        public Element transform(Element elem) throws JDOMException {
            ODSingleXMLDocument.this.prefix(elem, false);
            return elem;
        }
    };

    /**
     * Ajoute dans ce document seulement les éléments de doc correspondant au XPath spécifié et dont
     * la valeur de l'attribut style:name n'existe pas déjà.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @param topElem eg "office:font-decls".
     * @param elemToMerge les éléments à fusionner (par rapport à topElem), eg "style:font-decl".
     * @return les noms des éléments ajoutés.
     * @throws JDOMException
     * @see #mergeUnique(ODSingleXMLDocument, String, String, ElementTransformer)
     */
    private List<String> mergeUnique(ODXMLDocument doc, String topElem, String elemToMerge) throws JDOMException {
        return this.mergeUnique(doc, topElem, elemToMerge, NOP_ElementTransformer);
    }

    /**
     * Ajoute dans ce document seulement les éléments de doc correspondant au XPath spécifié et dont
     * la valeur de l'attribut style:name n'existe pas déjà. En conséquence n'ajoute que les
     * éléments possédant un attribut style:name.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @param topElem eg "office:font-decls".
     * @param elemToMerge les éléments à fusionner (par rapport à topElem), eg "style:font-decl".
     * @param addTransf la transformation à appliquer avant d'ajouter.
     * @return les noms des éléments ajoutés.
     * @throws JDOMException
     */
    private List<String> mergeUnique(ODXMLDocument doc, String topElem, String elemToMerge, ElementTransformer addTransf) throws JDOMException {
        return this.mergeUnique(doc, topElem, elemToMerge, "style:name", addTransf);
    }

    private List<String> mergeUnique(ODXMLDocument doc, String topElem, String elemToMerge, String attrFQName, ElementTransformer addTransf) throws JDOMException {
        List<String> added = new ArrayList<String>();
        Element thisParent = this.getChild(topElem, true);

        XPath xp = this.getXPath("./" + elemToMerge + "/@" + attrFQName);

        // les styles de ce document
        List thisElemNames = xp.selectNodes(thisParent);
        // on transforme la liste d'attributs en liste de String
        CollectionUtils.transform(thisElemNames, new Transformer() {
            public Object transform(Object obj) {
                return ((Attribute) obj).getValue();
            }
        });

        // pour chaque style de l'autre document
        Iterator otherElemNames = xp.selectNodes(doc.getChild(topElem)).iterator();
        while (otherElemNames.hasNext()) {
            Attribute attr = (Attribute) otherElemNames.next();
            // on l'ajoute si non déjà dedans
            if (!thisElemNames.contains(attr.getValue())) {
                thisParent.addContent(addTransf.transform((Element) attr.getParent().clone()));
                added.add(attr.getValue());
            }
        }

        return added;
    }

    /**
     * Ajoute l'élément elemName de doc, s'il n'est pas dans ce document.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @param elemName l'élément à ajouter, eg "outline-style".
     * @throws JDOMException if elemName is not valid.
     */
    private void addStylesIfNotPresent(ODXMLDocument doc, String elemName) throws JDOMException {
        this.addIfNotPresent(doc, "./office:styles/text:" + elemName);
    }

    /**
     * Prefixe les fils de auto-styles possédant un attribut "name" avant de les ajouter.
     * 
     * @param doc le document à fusionner avec celui-ci.
     * @return les élément ayant été ajoutés.
     * @throws JDOMException
     */
    private List<Element> prefixAndAddAutoStyles(ODXMLDocument doc) throws JDOMException {
        final List<Element> result = new ArrayList<Element>(128);
        final List otherNames = this.getXPath("./*/@style:name").selectNodes(doc.getChild("automatic-styles"));
        Iterator iter = otherNames.iterator();
        while (iter.hasNext()) {
            Attribute attr = (Attribute) iter.next();
            Element parent = (Element) attr.getParent().clone();
            parent.setAttribute("name", this.prefix(attr.getValue()), this.getNS().getSTYLE());
            this.getChild("automatic-styles").addContent(parent);
            result.add(parent);
        }
        return result;
    }

    /**
     * Saves this OO document to a file.
     * 
     * @param f the file where this document will be saved, without extension, eg "dir/myfile".
     * @return the actual file where it has been saved (with extension), eg "dir/myfile.odt".
     * @throws IOException if an error occurs.
     */
    public File saveAs(File f) throws IOException {
        return this.pkg.saveAs(f);
    }

    // FIXME dépend de styles définis dans Ideation/styles.in.xml
    private Element getPageBreak() {
        return new Element("p", this.getNS().getTEXT()).setAttribute("style-name", "PageBreak", this.getNS().getTEXT());
    }

}