package ezvcard.io.xml;

import static ezvcard.io.xml.XCardQNames.GROUP;
import static ezvcard.io.xml.XCardQNames.PARAMETERS;
import static ezvcard.io.xml.XCardQNames.VCARD;
import static ezvcard.io.xml.XCardQNames.VCARDS;

import java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

import javax.xml.namespace.QName;
import javax.xml.transform.ErrorListener;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.sax.SAXResult;
import javax.xml.transform.stream.StreamSource;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import ezvcard.VCard;
import ezvcard.VCardVersion;
import ezvcard.io.CannotParseException;
import ezvcard.io.EmbeddedVCardException;
import ezvcard.io.ParseWarning;
import ezvcard.io.SkipMeException;
import ezvcard.io.StreamReader;
import ezvcard.io.scribe.VCardPropertyScribe;
import ezvcard.parameter.VCardParameters;
import ezvcard.property.VCardProperty;
import ezvcard.property.Xml;
import ezvcard.util.ClearableStringBuilder;
import ezvcard.util.XmlUtils;

/*
 Copyright (c) 2012-2020, Michael Angstadt
 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. 

 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 COPYRIGHT OWNER 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.

 The views and conclusions contained in the software and documentation are those
 of the authors and should not be interpreted as representing official policies, 
 either expressed or implied, of the FreeBSD Project.
 */

/**
 * <p>
 * Reads xCards (XML-encoded vCards) in a streaming fashion.
 * </p>
 * <p>
 * <b>Example:</b>
 * </p>
 * 
 * <pre class="brush:java">
 * File file = new File("vcards.xml");
 * XCardReader reader = null;
 * try {
 *   reader = new XCardReader(file);
 *   VCard vcard;
 *   while ((vcard = reader.readNext()) != null) {
 *     //...
 *   }
 * } finally {
 *   if (reader != null) reader.close();
 * }
 * </pre>
 * 
 * @author Michael Angstadt
 * @see <a href="http://tools.ietf.org/html/rfc6351">RFC 6351</a>
 */
public class XCardReader extends StreamReader {
	private final VCardVersion version = VCardVersion.V4_0;
	private final String NS = version.getXmlNamespace();

	private final Source source;
	private final Closeable stream;

	private volatile VCard readVCard;
	private volatile TransformerException thrown;

	private final ReadThread thread = new ReadThread();
	private final Object lock = new Object();
	private final BlockingQueue<Object> readerBlock = new ArrayBlockingQueue<Object>(1);
	private final BlockingQueue<Object> threadBlock = new ArrayBlockingQueue<Object>(1);

	/**
	 * @param xml the XML to read from
	 */
	public XCardReader(String xml) {
		this(new StringReader(xml));
	}

	/**
	 * @param in the input stream to read from
	 */
	public XCardReader(InputStream in) {
		source = new StreamSource(in);
		stream = in;
	}

	/**
	 * @param file the file to read from
	 * @throws FileNotFoundException if the file doesn't exist
	 */
	public XCardReader(File file) throws FileNotFoundException {
		this(new BufferedInputStream(new FileInputStream(file)));
	}

	/**
	 * @param reader the reader to read from
	 */
	public XCardReader(Reader reader) {
		source = new StreamSource(reader);
		stream = reader;
	}

	/**
	 * @param node the DOM node to read from
	 */
	public XCardReader(Node node) {
		source = new DOMSource(node);
		stream = null;
	}

	@Override
	protected VCard _readNext() throws IOException {
		readVCard = null;
		thrown = null;
		context.setVersion(version);

		if (!thread.started) {
			thread.start();
		} else {
			if (thread.finished || thread.closed) {
				return null;
			}

			try {
				threadBlock.put(lock);
			} catch (InterruptedException e) {
				return null;
			}
		}

		//wait until thread reads xCard
		try {
			readerBlock.take();
		} catch (InterruptedException e) {
			return null;
		}

		if (thrown != null) {
			throw new IOException(thrown);
		}

		return readVCard;
	}

	private class ReadThread extends Thread {
		private final SAXResult result;
		private final Transformer transformer;
		private volatile boolean finished = false, started = false, closed = false;

		public ReadThread() {
			setName(getClass().getSimpleName());

			//create the transformer
			try {
				TransformerFactory factory = TransformerFactory.newInstance();
				XmlUtils.applyXXEProtection(factory);

				transformer = factory.newTransformer();
			} catch (TransformerConfigurationException e) {
				//shouldn't be thrown because it's a simple configuration
				throw new RuntimeException(e);
			}

			//prevent error messages from being printed to stderr
			transformer.setErrorListener(new NoOpErrorListener());

			result = new SAXResult(new ContentHandlerImpl());
		}

		@Override
		public void run() {
			started = true;

			try {
				transformer.transform(source, result);
			} catch (TransformerException e) {
				if (!thread.closed) {
					thrown = e;
				}
			} finally {
				finished = true;
				try {
					readerBlock.put(lock);
				} catch (InterruptedException e) {
					//ignore
				}
			}
		}
	}

	private class ContentHandlerImpl extends DefaultHandler {
		private final Document DOC = XmlUtils.createDocument();
		private final XCardStructure structure = new XCardStructure();
		private final ClearableStringBuilder characterBuffer = new ClearableStringBuilder();

		private String group;
		private Element propertyElement, parent;
		private QName paramName;
		private VCardParameters parameters;

		@Override
		public void characters(char[] buffer, int start, int length) throws SAXException {
			/*
			 * Ignore all text nodes that are outside of a property element. All
			 * valid text nodes will be inside of property elements (parameter
			 * values and property values)
			 */
			if (propertyElement == null) {
				return;
			}

			characterBuffer.append(buffer, start, length);
		}

		@Override
		public void startElement(String namespace, String localName, String qName, Attributes attributes) throws SAXException {
			QName qname = new QName(namespace, localName);
			String textContent = characterBuffer.getAndClear();

			if (structure.isEmpty()) {
				//<vcards>
				if (VCARDS.equals(qname)) {
					structure.push(ElementType.vcards);
				}
				return;
			}

			ElementType parentType = structure.peek();
			ElementType typeToPush = null;

			if (parentType != null) {
				switch (parentType) {
				case vcards:
					//<vcard>
					if (VCARD.equals(qname)) {
						readVCard = new VCard();
						readVCard.setVersion(version);
						typeToPush = ElementType.vcard;
					}
					break;

				case vcard:
					//<group>
					if (GROUP.equals(qname)) {
						group = attributes.getValue("name");
						typeToPush = ElementType.group;
					} else {
						propertyElement = createElement(namespace, localName, attributes);
						parameters = new VCardParameters();
						parent = propertyElement;
						typeToPush = ElementType.property;
					}
					break;

				case group:
					propertyElement = createElement(namespace, localName, attributes);
					parameters = new VCardParameters();
					parent = propertyElement;
					typeToPush = ElementType.property;
					break;

				case property:
					//<parameters>
					if (PARAMETERS.equals(qname)) {
						typeToPush = ElementType.parameters;
					}
					break;

				case parameters:
					//inside of <parameters>
					if (NS.equals(namespace)) {
						paramName = qname;
						typeToPush = ElementType.parameter;
					}
					break;

				case parameter:
					if (NS.equals(namespace)) {
						typeToPush = ElementType.parameterValue;
					}
					break;

				case parameterValue:
					//should never have child elements
					break;
				}
			}

			//append to property element
			if (propertyElement != null && typeToPush != ElementType.property && typeToPush != ElementType.parameters && !structure.isUnderParameters()) {
				if (textContent.length() > 0) {
					parent.appendChild(DOC.createTextNode(textContent));
				}
				Element element = createElement(namespace, localName, attributes);
				parent.appendChild(element);
				parent = element;
			}

			structure.push(typeToPush);
		}

		@Override
		public void endElement(String namespace, String localName, String qName) throws SAXException {
			String textContent = characterBuffer.getAndClear();

			if (structure.isEmpty()) {
				//no <vcards> elements were read yet
				return;
			}

			ElementType type = structure.pop();
			if (type == null && (propertyElement == null || structure.isUnderParameters())) {
				//it's a non-xCard element
				return;
			}

			if (type != null) {
				switch (type) {
				case parameterValue:
					parameters.put(paramName.getLocalPart(), textContent);
					break;

				case parameter:
					//do nothing
					break;

				case parameters:
					//do nothing
					break;

				case property:
					propertyElement.appendChild(DOC.createTextNode(textContent));

					String propertyName = localName;
					VCardProperty property;
					QName propertyQName = new QName(propertyElement.getNamespaceURI(), propertyElement.getLocalName());
					VCardPropertyScribe<? extends VCardProperty> scribe = index.getPropertyScribe(propertyQName);

					context.getWarnings().clear();
					context.setPropertyName(propertyName);
					try {
						property = scribe.parseXml(propertyElement, parameters, context);
						property.setGroup(group);
						readVCard.addProperty(property);
						warnings.addAll(context.getWarnings());
					} catch (SkipMeException e) {
						//@formatter:off
						warnings.add(new ParseWarning.Builder(context)
							.message(22, e.getMessage())
							.build()
						);
						//@formatter:on
					} catch (CannotParseException e) {
						//@formatter:off
						warnings.add(new ParseWarning.Builder(context)
							.message(e)
							.build()
						);
						//@formatter:on

						scribe = index.getPropertyScribe(Xml.class);
						property = scribe.parseXml(propertyElement, parameters, context);
						property.setGroup(group);
						readVCard.addProperty(property);
					} catch (EmbeddedVCardException e) {
						//@formatter:off
						warnings.add(new ParseWarning.Builder(context)
							.message(34)
							.build()
						);
						//@formatter:on
					}

					propertyElement = null;
					break;

				case group:
					group = null;
					break;

				case vcard:
					//wait for readNext() to be called again
					try {
						readerBlock.put(lock);
						threadBlock.take();
					} catch (InterruptedException e) {
						throw new SAXException(e);
					}
					break;

				case vcards:
					//do nothing
					break;
				}
			}

			//append element to property element
			if (propertyElement != null && type != ElementType.property && type != ElementType.parameters && !structure.isUnderParameters()) {
				if (textContent.length() > 0) {
					parent.appendChild(DOC.createTextNode(textContent));
				}
				parent = (Element) parent.getParentNode();
			}
		}

		private Element createElement(String namespace, String localName, Attributes attributes) {
			Element element = DOC.createElementNS(namespace, localName);
			applyAttributesTo(element, attributes);
			return element;
		}

		private void applyAttributesTo(Element element, Attributes attributes) {
			for (int i = 0; i < attributes.getLength(); i++) {
				String qname = attributes.getQName(i);
				if (qname.startsWith("xmlns:")) {
					continue;
				}

				String name = attributes.getLocalName(i);
				String value = attributes.getValue(i);
				element.setAttribute(name, value);
			}
		}
	}

	private enum ElementType {
		//enum values are lower-case so they won't get confused with the "XCardQNames" variable names
		vcards, vcard, group, property, parameters, parameter, parameterValue
	}

	/**
	 * <p>
	 * Keeps track of the structure of an xCard XML document.
	 * </p>
	 * 
	 * <p>
	 * Note that this class is here because you can't just do QName comparisons
	 * on a one-by-one basis. The location of an XML element within the XML
	 * document is important too. It's possible for two elements to have the
	 * same QName, but be treated differently depending on their location (e.g.
	 * a parameter named "parameters")
	 * </p>
	 */
	private static class XCardStructure {
		private final List<ElementType> stack = new ArrayList<ElementType>();

		/**
		 * Pops the top element type off the stack.
		 * @return the element type or null if the stack is empty
		 */
		public ElementType pop() {
			return isEmpty() ? null : stack.remove(stack.size() - 1);
		}

		/**
		 * Looks at the top element type.
		 * @return the top element type or null if the stack is empty
		 */
		public ElementType peek() {
			return isEmpty() ? null : stack.get(stack.size() - 1);
		}

		/**
		 * Adds an element type to the stack.
		 * @param type the type to add or null if the XML element is not an
		 * xCard element
		 */
		public void push(ElementType type) {
			stack.add(type);
		}

		/**
		 * Determines if the leaf node is under a {@code <parameters>} element.
		 * @return true if it is, false if not
		 */
		public boolean isUnderParameters() {
			//get the first non-null type
			ElementType nonNull = null;
			for (int i = stack.size() - 1; i >= 0; i--) {
				ElementType type = stack.get(i);
				if (type != null) {
					nonNull = type;
					break;
				}
			}

			//@formatter:off
			return
			nonNull == ElementType.parameters ||
			nonNull == ElementType.parameter ||
			nonNull == ElementType.parameterValue;
			//@formatter:on
		}

		/**
		 * Determines if the stack is empty
		 * @return true if the stack is empty, false if not
		 */
		public boolean isEmpty() {
			return stack.isEmpty();
		}
	}

	/**
	 * An implementation of {@link ErrorListener} that doesn't do anything.
	 */
	private static class NoOpErrorListener implements ErrorListener {
		public void error(TransformerException e) {
			//do nothing
		}

		public void fatalError(TransformerException e) {
			//do nothing
		}

		public void warning(TransformerException e) {
			//do nothing
		}
	}

	/**
	 * Closes the underlying input stream.
	 */
	public void close() throws IOException {
		if (thread.isAlive()) {
			thread.closed = true;
			thread.interrupt();
		}

		if (stream != null) {
			stream.close();
		}
	}
}
