/*******************************************************************************
 * Copyright (c) 2010, 2012 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.launching;

import java.io.BufferedInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * Parses an XML property list into its associated objects.
 */
public class PListParser {

	/**
	 * Constants for XML element names and attributes
	 */
	private static final String PLIST_ELEMENT = "plist"; //$NON-NLS-1$
	private static final String KEY_ELEMENT = "key"; //$NON-NLS-1$
	private static final String DICT_ELEMENT = "dict"; //$NON-NLS-1$
	private static final String ARRAY_ELEMENT = "array"; //$NON-NLS-1$
	private static final String TRUE_ELEMENT = "true"; //$NON-NLS-1$
	private static final String FALSE_ELEMENT = "false"; //$NON-NLS-1$
	private static final String INT_ELEMENT = "integer"; //$NON-NLS-1$
	private static final String STRING_ELEMENT = "string"; //$NON-NLS-1$

	/**
	 * Parses the given input stream which corresponds to an XML plist. See the DTD
	 * here: http://www.apple.com/DTDs/PropertyList-1.0.dtd
	 *
	 * @param stream XML plist input stream
	 * @return Object(s) in the stream
	 * @throws CoreException if an error occurs
	 */
	public Object parse(InputStream stream) throws CoreException {
		try {
			stream = new BufferedInputStream(stream);
			return parseXML(stream);
		} catch (FileNotFoundException e) {
			abort(e);
		} catch (SAXException e) {
			abort(e);
		} catch (ParserConfigurationException e) {
			abort(e);
		} catch (IOException e) {
			abort(e);
		} finally {
			if (stream != null) {
				try {
					stream.close();
				} catch (IOException e) {
					abort(e);
				}
			}
		}
		return null;
	}

	/**
	 * Return a LaunchConfigurationInfo object initialized from XML contained in
	 * the specified stream.  Simply pass out any exceptions encountered so that
	 * caller can deal with them.  This is important since caller may need access to the
	 * actual exception.
	 * @param stream the stream
	 * @return the result of the parse
	 * @throws CoreException if an error occurs
	 * @throws ParserConfigurationException if the parser set-up fails
	 * @throws IOException if reading the stream fails
	 * @throws SAXException if parsing itself fails
	 */
	private Object parseXML(InputStream stream) throws CoreException, ParserConfigurationException, IOException, SAXException {
		Element root = null;
		DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
		try {
			documentBuilderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); //$NON-NLS-1$
		}
		catch(ParserConfigurationException pce) {
			//do nothing the document builder does not support the load-external-dtd feature
			//see https://bugs.eclipse.org/bugs/show_bug.cgi?id=386188
		}
		DocumentBuilder parser = documentBuilderFactory.newDocumentBuilder();
		parser.setErrorHandler(new DefaultHandler());
		root = parser.parse(new InputSource(stream)).getDocumentElement();
		if (!root.getNodeName().equalsIgnoreCase(PLIST_ELEMENT)) {
			throw getInvalidFormatException();
		}
		NodeList list = root.getChildNodes();
		Node node = null;
		Element element = null;
		for (int i = 0; i < list.getLength(); ++i) {
			node = list.item(i);
			short nodeType = node.getNodeType();
			if (nodeType == Node.ELEMENT_NODE) {
				element = (Element) node;
				return parseObject(element);
			}
		}
		return null;
	}

	/**
	 * Returns an invalid format exception
	 *
	 * @return an invalid format exception
	 */
	private CoreException getInvalidFormatException() {
		return new CoreException(new Status(IStatus.ERROR, LaunchingPlugin.ID_PLUGIN, "Invalid plist XML", null)); //$NON-NLS-1$
	}

	/**
	 * Parses and returns an object from the given root element, possibly <code>null</code>.
	 *
	 * @param element the root node from the XML document
	 * @return parsed object or <code>null</code>
	 * @throws CoreException if an error occurs
	 */
	private Object parseObject(Element element) throws CoreException {
		String nodeName = element.getNodeName();
		if (nodeName.equalsIgnoreCase(ARRAY_ELEMENT)) {
			return parseArray(element);
		} else if (nodeName.equalsIgnoreCase(DICT_ELEMENT)) {
			return parseDictionary(element);
		} else if (nodeName.equalsIgnoreCase(KEY_ELEMENT))  {
			return getText(element);
		} else if (nodeName.equalsIgnoreCase(TRUE_ELEMENT)) {
			return Boolean.TRUE;
		} else if (nodeName.equalsIgnoreCase(FALSE_ELEMENT)) {
			return Boolean.FALSE;
		} else if(nodeName.equalsIgnoreCase(INT_ELEMENT)) {
			try {
				return new Integer(Integer.parseInt(getText(element)));
			} catch (NumberFormatException e) {
				abort(e);
			}
		} else if (nodeName.equalsIgnoreCase(STRING_ELEMENT)) {
			return getText(element);
		}
		return null;
	}

	/**
	 * Parses and returns an array from the given root element, possibly empty.
	 *
	 * @param root the root array node from the XML document
	 * @return parsed array or <code>null</code>
	 * @throws CoreException if an error occurs
	 */
	private Object[] parseArray(Element root) throws CoreException {
		List<Object> collection = new ArrayList<>();
		NodeList list = root.getChildNodes();
		Node node = null;
		Element element = null;
		for (int i = 0; i < list.getLength(); ++i) {
			node = list.item(i);
			short nodeType = node.getNodeType();
			if (nodeType == Node.ELEMENT_NODE) {
				element = (Element) node;
				Object obj = parseObject(element);
				if (obj != null) {
					collection.add(obj);
				}
			}
		}
		return collection.toArray();
	}

	/**
	 * Parses and returns a map from the given dictionary element, possibly empty.
	 *
	 * @param root the root dictionary node from the XML document
	 * @return parsed map or <code>null</code>
	 * @throws CoreException if an error occurs
	 */
	private Map<String, Object> parseDictionary(Element root) throws CoreException {
		Map<String, Object> dict = new HashMap<>();
		NodeList list = root.getChildNodes();
		Node node = null;
		Element element = null;
		String nodeName = null;
		String key = null;
		for (int i = 0; i < list.getLength(); ++i) {
			node = list.item(i);
			short nodeType = node.getNodeType();
			if (nodeType == Node.ELEMENT_NODE) {
				element = (Element) node;
				nodeName = element.getNodeName();
				if (nodeName.equalsIgnoreCase(KEY_ELEMENT)) {
					key = getText(element);
				} else {
					dict.put(key, parseObject(element));
				}
			}
		}
		return dict;
	}

	/**
	 * Returns the value of the first child text node from the given element,
	 * or <code>null</code>.
	 *
	 * @param root the root element
	 * @return its text or <code>null</code> if none
	 */
	private String getText(Element root) {
		NodeList list = root.getChildNodes();
		Node node = null;
		for (int i = 0; i < list.getLength(); ++i) {
			node = list.item(i);
			short nodeType = node.getNodeType();
			if (nodeType == Node.TEXT_NODE) {
				return ((Text) node).getNodeValue();
			}
		}
		return null;
	}

	private void abort(Throwable t) throws CoreException {
		throw new CoreException(new Status(IStatus.ERROR, LaunchingPlugin.ID_PLUGIN, "Exception occurred parsing property list", t)); //$NON-NLS-1$
	}
}