/*
 * The MIT License
 * Copyright (c) 2012 Microsoft Corporation
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package microsoft.exchange.webservices.data.core;

import microsoft.exchange.webservices.data.core.enumeration.misc.XmlNamespace;
import microsoft.exchange.webservices.data.core.exception.service.local.ServiceXmlSerializationException;
import microsoft.exchange.webservices.data.misc.OutParam;
import microsoft.exchange.webservices.data.property.complex.ISearchStringProvider;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.w3c.dom.CDATASection;
import org.w3c.dom.Comment;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.EntityReference;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.ProcessingInstruction;
import org.w3c.dom.Text;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;

/**
 * Stax based XML Writer implementation.
 */
public class EwsServiceXmlWriter implements IDisposable {

  private static final Log LOG = LogFactory.getLog(EwsServiceXmlWriter.class);

  /**
   * The is disposed.
   */
  private boolean isDisposed;

  /**
   * The service.
   */
  private ExchangeServiceBase service;

  /**
   * The xml writer.
   */
  private XMLStreamWriter xmlWriter;

  /**
   * The is time zone header emitted.
   */
  private boolean isTimeZoneHeaderEmitted;

  /**
   * The Buffer size.
   */
  private static final int BufferSize = 4096;

  /**
   * The  requireWSSecurityUtilityNamespace *
   */

  protected boolean requireWSSecurityUtilityNamespace;

  /**
   * Initializes a new instance.
   *
   * @param service the service
   * @param stream the stream
   * @throws XMLStreamException the XML stream exception
   */
  public EwsServiceXmlWriter(ExchangeServiceBase service, OutputStream stream) throws XMLStreamException {
    this.service = service;
    XMLOutputFactory xmlof = XMLOutputFactory.newInstance();
    xmlWriter = xmlof.createXMLStreamWriter(stream, "utf-8");

  }

  /**
   * Try to convert object to a string.
   *
   * @param value The value.
   * @param str   the str
   * @return True if object was converted, false otherwise. A null object will
   * be "successfully" converted to a null string.
   */
  protected boolean tryConvertObjectToString(Object value,
      OutParam<String> str) {
    boolean converted = true;
    str.setParam(null);
    if (value != null) {
      if (value.getClass().isEnum()) {
        str.setParam(EwsUtilities.serializeEnum(value));
      } else if (value.getClass().equals(Boolean.class)) {
        str.setParam(EwsUtilities.boolToXSBool((Boolean) value));
      } else if (value instanceof Date) {
        str
            .setParam(this.service
                .convertDateTimeToUniversalDateTimeString(
                    (Date) value));
      } else if (value.getClass().isPrimitive()) {
        str.setParam(value.toString());
      } else if (value instanceof String) {
        str.setParam(value.toString());
      } else if (value instanceof ISearchStringProvider) {
        ISearchStringProvider searchStringProvider =
            (ISearchStringProvider) value;
        str.setParam(searchStringProvider.getSearchString());
      } else if (value instanceof Number) {
        str.setParam(value.toString());
      } else {
        converted = false;
      }
    }
    return converted;
  }

  /**
   * Performs application-defined tasks associated with freeing, releasing, or
   * resetting unmanaged resources.
   */
  @Override
  public void dispose() {
    if (!this.isDisposed) {
      try {
        this.xmlWriter.close();
      } catch (XMLStreamException e) {
        LOG.error(e);
      }
      this.isDisposed = true;
    }
  }

  /**
   * Flushes this instance.
   *
   * @throws XMLStreamException the XML stream exception
   */
  public void flush() throws XMLStreamException {
    this.xmlWriter.flush();
  }

  /**
   * Writes the start element.
   *
   * @param xmlNamespace the XML namespace
   * @param localName    the local name of the element
   * @throws XMLStreamException the XML stream exception
   */
  public void writeStartElement(XmlNamespace xmlNamespace, String localName)
      throws XMLStreamException {
    String strPrefix = EwsUtilities.getNamespacePrefix(xmlNamespace);
    String strNameSpace = EwsUtilities.getNamespaceUri(xmlNamespace);
    this.xmlWriter.writeStartElement(strPrefix, localName, strNameSpace);
  }

  /**
   * Writes the end element.
   *
   * @throws XMLStreamException the XML stream exception
   */
  public void writeEndElement() throws XMLStreamException {
    this.xmlWriter.writeEndElement();
  }

  /**
   * Writes the attribute value.
   *
   * @param localName the local name of the attribute
   * @param value     the value
   * @throws ServiceXmlSerializationException the service xml serialization exception
   */
  public void writeAttributeValue(String localName, Object value)
      throws ServiceXmlSerializationException {
    this.writeAttributeValue(localName,
        false /* alwaysWriteEmptyString */, value);
  }

  /**
   * Writes the attribute value.  Optionally emits empty string values.
   *
   * @param localName              the local name of the attribute.
   * @param alwaysWriteEmptyString always emit the empty string as the value.
   * @param value                  the value
   * @throws ServiceXmlSerializationException the service xml serialization exception
   */
  public void writeAttributeValue(String localName,
      boolean alwaysWriteEmptyString,
      Object value) throws ServiceXmlSerializationException {
    OutParam<String> stringOut = new OutParam<String>();
    String stringValue = null;
    if (this.tryConvertObjectToString(value, stringOut)) {
      stringValue = stringOut.getParam();
      if ((null != stringValue) && (alwaysWriteEmptyString || (stringValue.length() != 0))) {
        this.writeAttributeString(localName, stringValue);
      }
    } else {
      throw new ServiceXmlSerializationException(String.format(
          "Values of type '%s' can't be used for the '%s' attribute.", value.getClass()
              .getName(), localName));
    }
  }

  /**
   * Writes the attribute value.
   *
   * @param namespacePrefix the namespace prefix
   * @param localName       the local name of the attribute
   * @param value           the value
   * @throws ServiceXmlSerializationException the service xml serialization exception
   */
  public void writeAttributeValue(String namespacePrefix, String localName,
      Object value) throws ServiceXmlSerializationException {
    OutParam<String> stringOut = new OutParam<String>();
    String stringValue = null;
    if (this.tryConvertObjectToString(value, stringOut)) {
      stringValue = stringOut.getParam();
      if (null != stringValue && !stringValue.isEmpty()) {
        this.writeAttributeString(namespacePrefix, localName,
            stringValue);
      }
    } else {
      throw new ServiceXmlSerializationException(String.format(
          "Values of type '%s' can't be used for the '%s' attribute.", value.getClass()
              .getName(), localName));
    }
  }

  /**
   * Writes the attribute value.
   *
   * @param localName   The local name of the attribute.
   * @param stringValue The string value.
   * @throws ServiceXmlSerializationException Thrown if string value isn't valid for XML
   */
  protected void writeAttributeString(String localName, String stringValue)
      throws ServiceXmlSerializationException {
    try {
      this.xmlWriter.writeAttribute(localName, stringValue);
    } catch (XMLStreamException e) {
      // Bug E14:65046: XmlTextWriter will throw ArgumentException
      //if string includes invalid characters.
      throw new ServiceXmlSerializationException(String.format(
          "The invalid value '%s' was specified for the '%s' attribute.", stringValue, localName), e);
    }
  }

  /**
   * Writes the attribute value.
   *
   * @param namespacePrefix The namespace prefix.
   * @param localName       The local name of the attribute.
   * @param stringValue     The string value.
   * @throws ServiceXmlSerializationException Thrown if string value isn't valid for XML.
   */
  protected void writeAttributeString(String namespacePrefix,
      String localName, String stringValue)
      throws ServiceXmlSerializationException {
    try {
      this.xmlWriter.writeAttribute(namespacePrefix, "", localName,
          stringValue);
    } catch (XMLStreamException e) {
      // Bug E14:65046: XmlTextWriter will throw ArgumentException
      //if string includes invalid characters.
      throw new ServiceXmlSerializationException(String.format(
          "The invalid value '%s' was specified for the '%s' attribute.", stringValue, localName), e);
    }
  }

  /**
   * Writes string value.
   *
   * @param value The value.
   * @param name  Element name (used for error handling)
   * @throws ServiceXmlSerializationException Thrown if string value isn't valid for XML.
   */
  public void writeValue(String value, String name)
      throws ServiceXmlSerializationException {
    try {
      this.xmlWriter.writeCharacters(value);
    } catch (XMLStreamException e) {
      // Bug E14:65046: XmlTextWriter will throw ArgumentException
      //if string includes invalid characters.
      throw new ServiceXmlSerializationException(String.format(
          "The invalid value '%s' was specified for the '%s' element.", value, name), e);
    }
  }

  /**
   * Writes the element value.
   *
   * @param xmlNamespace the XML namespace
   * @param localName    the local name of the element
   * @param displayName  the name that should appear in the exception message when the value can not be serialized
   * @param value        the value
   * @throws XMLStreamException the XML stream exception
   * @throws ServiceXmlSerializationException the service xml serialization exception
   */
  public void writeElementValue(XmlNamespace xmlNamespace, String localName, String displayName, Object value)
      throws XMLStreamException, ServiceXmlSerializationException {
    String stringValue = null;
    OutParam<String> strOut = new OutParam<String>();

    if (this.tryConvertObjectToString(value, strOut)) {
      stringValue = strOut.getParam();
      if (null != stringValue) {
        // allow an empty string to create an empty element (like <Value
        // />).
        this.writeStartElement(xmlNamespace, localName);
        this.writeValue(stringValue, displayName);
        this.writeEndElement();
      }
    } else {
      throw new ServiceXmlSerializationException(String.format(
          "Values of type '%s' can't be used for the '%s' element.", value.getClass()
              .getName(), localName));
    }
  }

  public void writeNode(Node xmlNode) throws XMLStreamException {
    if (xmlNode != null) {
      writeNode(xmlNode, this.xmlWriter);
    }
  }

  /**
   * @param xmlNode XML node
   * @param xmlStreamWriter XML stream writer
   * @throws XMLStreamException the XML stream exception
   */
  public static void writeNode(Node xmlNode, XMLStreamWriter xmlStreamWriter)
      throws XMLStreamException {
    if (xmlNode instanceof Element) {
      addElement((Element) xmlNode, xmlStreamWriter);
    } else if (xmlNode instanceof Text) {
      xmlStreamWriter.writeCharacters(xmlNode.getNodeValue());
    } else if (xmlNode instanceof CDATASection) {
      xmlStreamWriter.writeCData(((CDATASection) xmlNode).getData());
    } else if (xmlNode instanceof Comment) {
      xmlStreamWriter.writeComment(((Comment) xmlNode).getData());
    } else if (xmlNode instanceof EntityReference) {
      xmlStreamWriter.writeEntityRef(xmlNode.getNodeValue());
    } else if (xmlNode instanceof ProcessingInstruction) {
      ProcessingInstruction procInst = (ProcessingInstruction) xmlNode;
      xmlStreamWriter.writeProcessingInstruction(procInst.getTarget(),
          procInst.getData());
    } else if (xmlNode instanceof Document) {
      writeToDocument((Document) xmlNode, xmlStreamWriter);
    }
  }

  /**
   * @param document XML document
   * @param xmlStreamWriter XML stream writer
   * @throws XMLStreamException the XML stream exception
   */
  public static void writeToDocument(Document document,
      XMLStreamWriter xmlStreamWriter) throws XMLStreamException {

    xmlStreamWriter.writeStartDocument();
    Element rootElement = document.getDocumentElement();
    addElement(rootElement, xmlStreamWriter);
    xmlStreamWriter.writeEndDocument();
  }

  /**
   * @param element DOM element
   * @param writer XML stream writer
   * @throws XMLStreamException the XML stream exception
   */
  public static void addElement(Element element, XMLStreamWriter writer)
      throws XMLStreamException {
    String nameSpace = element.getNamespaceURI();
    String prefix = element.getPrefix();
    String localName = element.getLocalName();
    if (prefix == null) {
      prefix = "";
    }
    if (localName == null) {
      localName = element.getNodeName();

      if (localName == null) {
        throw new IllegalStateException(
            "Element's local name cannot be null!");
      }
    }

    String decUri = writer.getNamespaceContext().getNamespaceURI(prefix);
    boolean declareNamespace = decUri == null || !decUri.equals(nameSpace);

    if (nameSpace == null || nameSpace.length() == 0) {
      writer.writeStartElement(localName);
    } else {
      writer.writeStartElement(prefix, localName, nameSpace);
    }

    NamedNodeMap attrs = element.getAttributes();
    for (int i = 0; i < attrs.getLength(); i++) {
      Node attr = attrs.item(i);

      String name = attr.getNodeName();
      String attrPrefix = "";
      int prefixIndex = name.indexOf(':');
      if (prefixIndex != -1) {
        attrPrefix = name.substring(0, prefixIndex);
        name = name.substring(prefixIndex + 1);
      }

      if ("xmlns".equals(attrPrefix)) {
        writer.writeNamespace(name, attr.getNodeValue());
        if (name.equals(prefix)
            && attr.getNodeValue().equals(nameSpace)) {
          declareNamespace = false;
        }
      } else {
        if ("xmlns".equals(name) && "".equals(attrPrefix)) {
          writer.writeNamespace("", attr.getNodeValue());
          if (attr.getNodeValue().equals(nameSpace)) {
            declareNamespace = false;
          }
        } else {
          writer.writeAttribute(attrPrefix, attr.getNamespaceURI(),
              name, attr.getNodeValue());
        }
      }
    }

    if (declareNamespace) {
      if (nameSpace == null) {
        writer.writeNamespace(prefix, "");
      } else {
        writer.writeNamespace(prefix, nameSpace);
      }
    }

    NodeList nodes = element.getChildNodes();
    for (int i = 0; i < nodes.getLength(); i++) {
      Node n = nodes.item(i);
      writeNode(n, writer);
    }


    writer.writeEndElement();

  }



  /**
   * Writes the element value.
   *
   * @param xmlNamespace the XML namespace
   * @param localName    the local name of the element
   * @param value        the value
   * @throws XMLStreamException the XML stream exception
   * @throws ServiceXmlSerializationException the service xml serialization exception
   */
  public void writeElementValue(XmlNamespace xmlNamespace, String localName,
      Object value) throws XMLStreamException,
      ServiceXmlSerializationException {
    this.writeElementValue(xmlNamespace, localName, localName, value);
  }

  /**
   * Writes the base64-encoded element value.
   *
   * @param buffer the buffer
   * @throws XMLStreamException the XML stream exception
   */
  public void writeBase64ElementValue(byte[] buffer)
      throws XMLStreamException {

    String strValue = Base64.encodeBase64String(buffer);
    this.xmlWriter.writeCharacters(strValue);//Base64.encode(buffer));
  }

  /**
   * Writes the base64-encoded element value.
   *
   * @param stream the stream
   * @throws IOException signals that an I/O exception has occurred
   * @throws XMLStreamException the XML stream exception
   */
  public void writeBase64ElementValue(InputStream stream) throws IOException,
      XMLStreamException {

    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    byte[] buf = new byte[BufferSize];
    try {
      for (int readNum; (readNum = stream.read(buf)) != -1; ) {
        bos.write(buf, 0, readNum);
      }
    } catch (IOException ex) {
      LOG.error(ex);
    } finally {
      bos.close();
    }
    byte[] bytes = bos.toByteArray();
    String strValue = Base64.encodeBase64String(bytes);
    this.xmlWriter.writeCharacters(strValue);

  }

  /**
   * Gets the internal XML writer.
   *
   * @return the internal writer
   */
  public XMLStreamWriter getInternalWriter() {
    return xmlWriter;
  }

  /**
   * Gets the service.
   *
   * @return The service.
   */
  public ExchangeServiceBase getService() {
    return service;
  }

  /**
   * Gets a value indicating whether the SOAP message need WSSecurity Utility namespace.
   */
  public boolean isRequireWSSecurityUtilityNamespace() {
    return requireWSSecurityUtilityNamespace;
  }

  /**
   * Sets a value indicating whether the SOAP message need WSSecurity Utility namespace.
   */
  public void setRequireWSSecurityUtilityNamespace(boolean requireWSSecurityUtilityNamespace) {
    this.requireWSSecurityUtilityNamespace = requireWSSecurityUtilityNamespace;
  }

  /**
   * Gets a value indicating whether the time zone SOAP header was emitted
   * through this writer.
   *
   * @return true if the time zone SOAP header was emitted; otherwise false.
   */
  public boolean isTimeZoneHeaderEmitted() {
    return isTimeZoneHeaderEmitted;
  }

  /**
   * Sets a value indicating whether the time zone SOAP header was emitted
   * through this writer.
   *
   * @param isTimeZoneHeaderEmitted true if the time zone SOAP header was emitted; otherwise
   *                                false.
   */
  public void setTimeZoneHeaderEmitted(boolean isTimeZoneHeaderEmitted) {
    this.isTimeZoneHeaderEmitted = isTimeZoneHeaderEmitted;
  }

  /**
   * Write start document.
   *
   * @throws XMLStreamException the XML stream exception
   */
  public void writeStartDocument() throws XMLStreamException {
    this.xmlWriter.writeStartDocument("utf-8", "1.0");
  }
}