/**
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander 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 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander 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.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.mucommander.xml;

import java.io.*;
import java.util.Iterator;

/**
 * Used to write pretty-printed XML content.
 * <p>
 * Application writers should keep in mind that this class does not perform any sort
 * of coherency check, and will not prevent them from closing elements they haven't opened yet,
 * or any other thing that would make the XML output invalid.
 * </p>
 * @author Nicolas Rinaudo
 */
public class XmlWriter {
    // - Constants -------------------------------------------------------
    // -------------------------------------------------------------------
    /** Number of space characters used for one level of indentation. */
    private static final int    OFFSET_INCREMENT    = 4;
    /** Identifier for publicly accessible objects. */
    public  static final String AVAILABILITY_PUBLIC = "PUBLIC";
    /** Identifier for system resources. */
    public  static final String AVAILABILITY_SYSTEM = "SYSTEM";
    /** Default output encoding. */
    public static final String  DEFAULT_ENCODING    = "UTF-8";



    // - XML standard entities -------------------------------------------
    // -------------------------------------------------------------------
    /** Forbiden XML characters. */
    private final static String[] ENTITIES            = new String[] {"&", "\"" , "'", "<", ">"};
    /** What to replace forbidden XML characters with. */
    private final static String[] ENTITY_REPLACEMENTS = new String[] {"&amp;", "&quot;", "&apos;", "&lt;", "&gt;"};



    // - Instance fields -------------------------------------------------
    // -------------------------------------------------------------------
    /** Where to write the XML content to. */
    private PrintWriter out;
    /** Current indentation offset. */
    private int         offset;
    /** Whether the next element opening or closing operation should be indented. */
    private boolean     printIndentation;



    // - Initialisation --------------------------------------------------
    // -------------------------------------------------------------------
    /**
     * Creates an <code>XmlWriter</code> that will write to the specified file.
     * <p>
     * This is a convenience constructor and is strictly equivalent to
     * <code>{@link #XmlWriter(OutputStream,String) XmlWriter}(new FileOutputStream(file), {@link #DEFAULT_ENCODING})</code>.
     * </p>
     * @param  file                  where to write XML output to.
     * @throws FileNotFoundException if <code>file</code> could not be found.
     * @throws IOException           if an I/O error occurs.
     */
    public XmlWriter(File file) throws IOException, FileNotFoundException {this(new FileOutputStream(file));}

    /**
     * Creates an <code>XmlWriter</code> that will write to the specified file using the specified encoding.
     * <p>
     * This is a convenience constructor and is strictly equivalent to
     * <code>{@link #XmlWriter(OutputStream,String) XmlWriter}(new FileOutputStream(file), encoding)</code>.
     * </p>
     * @param  file                         where to write XML output to.
     * @param  encoding                     encoding to use when writing the XML content.
     * @throws FileNotFoundException        if <code>file</code> could not be found.
     * @throws UnsupportedEncodingException if <code>encoding</code> is not supported.
     * @throws IOException                  if an I/O error occurs.
     */
    public XmlWriter(File file, String encoding) throws IOException, FileNotFoundException, UnsupportedEncodingException {this(new FileOutputStream(file), encoding);}

    /**
     * Creates an <code>XmlWriter</code> that will write to the specified output stream.
     * <p>
     * This is a convenience constructor and is strictly equivalent to
     * <code>{@link #XmlWriter(OutputStream,String) XmlWriter}(stream, {@link #DEFAULT_ENCODING})</code>.
     * </p>
     * @param  stream      where to write XML output to.
     * @throws IOException if an I/O error occurs.
     */
    public XmlWriter(OutputStream stream) throws IOException {init(new OutputStreamWriter(stream, DEFAULT_ENCODING), DEFAULT_ENCODING);}

    /**
     * Creates an <code>XmlWriter</code> that will write to the specified stream using the specified encoding.
     * @param  stream                       where to write XML output to.
     * @param  encoding                     encoding to use when writing the XML content.
     * @throws UnsupportedEncodingException if <code>encoding</code> is not supported.
     * @throws IOException                  if an I/O error occurs.
     */
    public XmlWriter(OutputStream stream, String encoding) throws UnsupportedEncodingException, IOException {init(new OutputStreamWriter(stream, encoding), encoding);}

    private void init(Writer writer, String encoding) throws IOException {
        out = new PrintWriter(writer, true);
        out.print("<?xml version=\"1.0\" encoding=\"");
        out.print(encoding);
        out.println("\"?>");
        if(out.checkError())
            throw new IOException();
    }


    // - Element operations --------------------------------------------------
    // -------------------------------------------------------------------
    /**
     * Writes the document type declaration of the XML file.
     * <p>
     * For the generated XML content to be valid, application writers should make sure that this
     * is the very first method they call. This class doesn't ensure coherency and won't
     * complain if the <code>DOCTYPE</code> statement is the last in the file.
     * </p>
     * <p>
     * Both <code>description</code> and <code>url</code> can be set to <code>null</code>.
     * If so, they will just be ignored.
     * </p>
     * @param  topElement   label of the top element in the XML file.
     * @param  availability availability of the document (expected to be either {@link #AVAILABILITY_PUBLIC}
     *                      or {@link #AVAILABILITY_SYSTEM}, but this is not enforced).
     * @param  description  description of the file (see DOCTYPE specifications for more information).
     * @param  url          URL at which the DTD of the XML file can be downloaded.
     * @throws IOException  if an I/O error occurs.
     */
    public void writeDocType(String topElement, String availability, String description, String url) throws IOException {
        // Writes the compulsory bits.
        out.print("<!DOCTYPE ");
        out.print(topElement);
        out.print(' ');
        out.print(availability);

        // Writes the description if present.
        if(description != null) {
            out.print(' ');
            out.print('\"');
            out.print(description);
            out.print('\"');
        }

        // Writes the DTD url if present.
        if(url != null) {
            out.print(' ');
            out.print('\"');
            out.print(url);
            out.print('\"');
        }
        out.println('>');
        if(out.checkError())
            throw new IOException();
    }

    /**
     * Writes an element opening sequence.
     * <p>
     * This is a convenience method and is strictly equivalent to calling
     * <code>{@link #startElement(String,boolean) startElement}(name, false)</code>.
     * </p>
     * @param  name        name of the element to open.
     * @throws IOException if an I/O error occurs.
     * @see                #startElement(String,XmlAttributes)
     * @see                #writeStandAloneElement(String)
     * @see                #writeStandAloneElement(String,XmlAttributes)
     */
    public void startElement(String name) throws IOException {startElement(name, false, null, false);}
    
    /**
     * Writes an element opening sequence.
     * <p>
     * Elements opened using this method will not have any attribute, and will
     * need to be closed using an {@link #endElement(String) endElement} call.
     * </p>
     * @param  name        name of the element to open.
     * @param  lineBreak   if <code>true</code>, a line break will be printed after the element declaration.
     * @throws IOException if an I/O error occurs.
     * @see                #startElement(String,XmlAttributes)
     * @see                #writeStandAloneElement(String)
     * @see                #writeStandAloneElement(String,XmlAttributes)
     */
    public void startElement(String name, boolean lineBreak) throws IOException {startElement(name, false, null, lineBreak);}

    /**
     * Writes a stand-alone element.
     * <p>
     * Elements opened using this method will not have any attributes, and will be
     * closed immediately.
     * </p>
     * <p>
     * A line break will always be printed after a stand-alone element.
     * </p>
     * @param  name        name of the element to write.
     * @throws IOException if an I/O error occurs.
     * @see                #startElement(String,XmlAttributes)
     * @see                #startElement(String)
     * @see                #writeStandAloneElement(String)
     */
    public void writeStandAloneElement(String name) throws IOException {startElement(name, true, null, true);}

    /**
     * Writes a one-line comment.
     * 
     * @param comment      comment description.
     * @throws IOException if an I/O error occurs.
     */
    public void writeCommentLine(String comment) throws IOException {
        out.print("<!-- " + comment + " -->");
        println();
    }
    
    /**
     * Writes an element opening sequence.
     * <p>
     * This is a convenience method and is strictly equivalent to calling
     * <code>{@link #startElement(String,XmlAttributes,boolean) startElement}(name, attributes, false)</code>.
     * </p>
     * @param  name        name of the element to open.
     * @throws IOException if an I/O error occurs.
     * @param  attributes  attributes that this element will have.
     * @see                #startElement(String)
     * @see                #writeStandAloneElement(String)
     * @see                #writeStandAloneElement(String,XmlAttributes)
     */
    public void startElement(String name, XmlAttributes attributes) throws IOException {startElement(name, false, attributes, false);}

    /**
     * Writes an element opening sequence.
     * <p>
     * Elements opened using this method will need to be closed using an {@link #endElement(String) endElement} call.
     * </p>
     * @param name         name of the element to open.
     * @param  attributes  attributes that this element will have.
     * @param  lineBreak   if <code>true</code>, a line break will be printed after the element declaration.
     * @throws IOException if an I/O error occurs.
     * @see                #startElement(String)
     * @see                #writeStandAloneElement(String)
     * @see                #writeStandAloneElement(String,XmlAttributes)
     */
    public void startElement(String name, XmlAttributes attributes, boolean lineBreak) throws IOException {startElement(name, false, attributes, lineBreak);}

    /**
     * Writes a stand-alone element.
     * <p>
     * Elements opened using this method will not need to be closed
     * </p>
     * <p>
     * A line break will always be printed after a stand-alone element.
     * </p>
     * @param name         name of the element to write.
     * @param attributes   attributes that this element will be closed immediately.
     * @throws IOException if an I/O error occurs.
     * @see                #startElement(String)
     * @see                #startElement(String,XmlAttributes)
     * @see                #writeStandAloneElement(String)
     */
    public void writeStandAloneElement(String name, XmlAttributes attributes) throws IOException {startElement(name, true, attributes, true);}

    /**
     * Writes an element opening sequence.
     * @param name         name of the element to open.
     * @param  isStandAlone whether or not this element should be closed immediately.
     * @param  attributes   XML attributes for this element.
     * @throws IOException  if an I/O error occurs.
     */
    private void startElement(String name, boolean isStandAlone, XmlAttributes attributes, boolean lineBreak) throws IOException {
        // Prints indentation if necessary.
        indent();

        // Opens the element.
        out.print('<');
        out.print(name);

        // Writes attributes, if any.
        if(attributes != null) {
            Iterator<String> names;
            String           attName;

            names = attributes.names();
            while(names.hasNext()) {
                attName = names.next();
                out.print(' ');
                out.print(attName);
                out.print("=\"");
                out.print(escape(attributes.getValue(attName)));
                out.print("\"");
            }
        }

        // Closes the element if necessary.
        if(isStandAlone)
            out.print('/');
        else
            offset += OFFSET_INCREMENT;

        // Finishes the element opening sequence.
        out.print('>');

        // Stand-alone elements are followed by a line break.
        if(lineBreak)
            println();

        if(out.checkError())
            throw new IOException();
    }

    /**
     * Writes an element closing sequence.
     * @param  name        name of the element to close.
     * @throws IOException if an I/O error occurs.
     */
    public void endElement(String name) throws IOException {
        // Updates the indentation, and prints it if necessary.
        offset -= OFFSET_INCREMENT;
        indent();

        // Writes the element closing sequence.
        out.print("</");
        out.print(name);
        out.print('>');
        println();

        if(out.checkError())
            throw new IOException();
    }



    // - CDATA handling --------------------------------------------------
    // -------------------------------------------------------------------
    /**
     * Writes the specified CDATA to the XML stream.
     * @param  cdata       content to write to the XML stream.
     * @throws IOException if an I/O error occurs.
     */
    public void writeCData(String cdata) throws IOException {
        indent();
        out.print(escape(cdata));
        if(out.checkError())
            throw new IOException();
    }

    /**
     * Escapes XML content, replacing special characters by their proper value.
     * @param   data       data to escape.
     * @return             the escaped content.
     * @throws IOException if an I/O error occurs.
     */
    public String escape(String data) throws IOException {
        int position;

        for(int i = 0; i < ENTITIES.length; i++) {
            position = 0;
            while((position = data.indexOf(ENTITIES[i], position)) != -1) {
                data = data.substring(0, position) + ENTITY_REPLACEMENTS[i] +
                    (position == data.length() -1 ? "" : data.substring(position + 1, data.length()));
                position = position + ENTITY_REPLACEMENTS[i].length();
            }
        }

        return data;
    }



    // - Indentation handling --------------------------------------------
    // -------------------------------------------------------------------
    /**
     * Prints a line break.
     * @throws IOException if an I/O error occurs.
     */
    public void println() throws IOException {
        out.println();
        printIndentation = true;
        if(out.checkError())
            throw new IOException();
    }

    /**
     * If necessary, prints indentation.
     * @throws IOException if an I/O error occurs.
     */
    private void indent() throws IOException {
        if(printIndentation) {
            for(int i = 0; i < offset; i++)
                out.print(' ');
            printIndentation = false;
        }
        if(out.checkError())
            throw new IOException();
    }



    // - Misc. -----------------------------------------------------------
    // -------------------------------------------------------------------
    /**
     * Closes the XML stream.
     * @throws IOException if an I/O error occurs.
     */
    public void close() throws IOException {out.close();}
}
