package com.github.markusbernhardt.proxy.util;

import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TimeZone;

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

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/**
 * Plist xml handling (serialization and deserialization)
 * <p>
 * <em>The xml plist dtd can be found at http://www.apple.com/DTDs/PropertyList-1.0.dtd</em>
 * <p>
 * The plist spec handles 8 types of objects: booleans, real, integers, dates,
 * binary data, strings, arrays (lists) and dictionaries (maps).
 * <p>
 * The java Plist lib handles converting xml plists to a nested
 * {@code Map<String, Object>} that can be trivially read from java. It also
 * provides a simple way to convert a nested {@code Map<String, Object>} into an
 * xml plist representation.
 * <p>
 * The following mapping will be done when converting from plist to <tt>Map</tt>
 * :
 * 
 * <pre>
 * true/false -&gt; Boolean
 * real -&gt; Double
 * integer -&gt; Integer/Long (depends on size, values exceeding an int will be rendered as longs)
 * data -&gt; byte[]
 * string -&gt; String
 * array -&gt; List
 * dict -&gt; Map
 * </pre>
 * <p>
 * When converting from Map -&gt; plist the conversion is as follows:
 * 
 * <pre>
 * Boolean -&gt; true/false
 * Float/Double -&gt; real
 * Byte/Short/Integer/Long -&gt; integer
 * byte[] -&gt; data
 * List -&gt; array
 * Map -&gt; dict
 * </pre>
 *
 * @author Markus Bernhardt, Copyright 2016
 * @author Bernd Rosstauscher, Copyright 2009
 * @author Christoffer Lerno / Modified by Bernd Rosstauscher
 */
public final class PListParser {
	/*****************************************************************************
	 * Exception is used for XML parse problems.
	 * 
	 * @author Bernd Rosstauscher (proxyvole@rosstauscher.de) Copyright 2009
	 ****************************************************************************/

	public static class XmlParseException extends Exception {

		/** Comment for <code>serialVersionUID</code> */
		private static final long serialVersionUID = 1L;

		/*************************************************************************
		 * Constructor
		 ************************************************************************/

		public XmlParseException() {
			super();
		}

		/*************************************************************************
		 * Constructor
		 * 
		 * @param msg
		 *            the error message
		 ************************************************************************/

		public XmlParseException(String msg) {
			super(msg);
		}

		/*************************************************************************
		 * Constructor
		 * 
		 * @param msg
		 *            error message
		 * @param e
		 *            the cause.
		 ************************************************************************/

		public XmlParseException(String msg, Exception e) {
			super(msg, e);
		}

	}

	/*****************************************************************************
	 * Small helper class representing a tree node.
	 * 
	 * @author Bernd Rosstauscher (proxyvole@rosstauscher.de) Copyright 2009
	 ****************************************************************************/

	public static class Dict implements Iterable<Map.Entry<String, Object>> {
		private Map<String, Object> children;

		/*************************************************************************
		 * Constructor
		 ************************************************************************/

		public Dict() {
			super();
			this.children = new HashMap<String, Object>();
		}

		/*************************************************************************
		 * @param key
		 *            of the child node.
		 * @return the child node, null if not existing.
		 ************************************************************************/

		public Object get(String key) {
			return this.children.get(key);
		}

		/*************************************************************************
		 * iterator
		 * 
		 * @see java.lang.Iterable#iterator()
		 ************************************************************************/

		public Iterator<Entry<String, Object>> iterator() {
			return this.children.entrySet().iterator();
		}

		/*************************************************************************
		 * @return the size of this dictionary.
		 ************************************************************************/

		public int size() {
			return this.children.size();
		}

		/*************************************************************************
		 * Dumps a dictionary with all sub-nodes to the console.
		 ************************************************************************/

		public void dump() {
			System.out.println("PList");
			dumpInternal(this, 1);
		}

		/*************************************************************************
		 * @param plist
		 * @param indent
		 ************************************************************************/

		private static void dumpInternal(Dict plist, int indent) {
			for (Map.Entry<String, Object> child : plist) {
				if (child.getValue() instanceof Dict) {
					for (int j = 0; j < indent; j++) {
						System.out.print("  ");
					}
					System.out.println(child.getKey());
					dumpInternal((Dict) child.getValue(), indent + 1);
				} else {
					for (int j = 0; j < indent; j++) {
						System.out.print("  ");
					}
					System.out.println(child.getKey() + " = " + child.getValue());
				}
			}

		}

		/*************************************************************************
		 * Get a node at a given path.
		 * 
		 * @param path
		 *            a / separated path into the plist hirarchy.
		 * @return the object located at the given path, null if it does not
		 *         exist.
		 ************************************************************************/

		public Object getAtPath(String path) {
			Dict currentNode = this;

			String[] pathSegments = path.trim().split("/");
			for (int i = 0; i < pathSegments.length; i++) {
				String segment = pathSegments[i].trim();
				if (segment.length() == 0) {
					continue;
				}
				Object o = currentNode.get(segment);
				if (i >= pathSegments.length - 1) {
					return o;
				}
				if (o == null || !(o instanceof Dict)) {
					break;
				}
				currentNode = (Dict) o;
			}
			return null;
		}

	}

	/**
	 * Singleton instance.
	 */
	private final static PListParser PLIST = new PListParser();

	/**
	 * All element types possible for a plist.
	 */
	private static enum ElementType {
		INTEGER, STRING, REAL, DATA, DATE, DICT, ARRAY, TRUE, FALSE,
	}

	private static final String BASE64_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
	private static final char[] BASE64_CHARS = BASE64_STRING.toCharArray();
	private final DateFormat m_dateFormat;
	private final Map<Class<?>, ElementType> m_simpleTypes;

	/**
	 * Utility method to close a closeable.
	 *
	 * @param closeable
	 *            or null.
	 */
	static void silentlyClose(Closeable closeable) {
		try {
			if (closeable != null) {
				closeable.close();
			}
		} catch (IOException e) {
			// Ignore
		}
	}

	/*************************************************************************
	 * Reads from the given input stream and parses it to a Dict.
	 * 
	 * @param input
	 *            the input stream to read from.
	 * @return the parsed dictionary.
	 * @throws XmlParseException
	 *             on parsing error.
	 ************************************************************************/

	private static Dict parse(InputSource input) throws XmlParseException {
		try {
			DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
			documentBuilder.setEntityResolver(new EmptyXMLResolver());
			Document doc = documentBuilder.parse(input);
			Element element = doc.getDocumentElement();
			return PLIST.parse(element);
		} catch (ParserConfigurationException e) {
			throw new XmlParseException("Error reading input", e);
		} catch (SAXException e) {
			throw new XmlParseException("Error reading input", e);
		} catch (IOException e) {
			throw new XmlParseException("Error reading input", e);
		}
	}

	/**
	 * Create a nested {@code map<String, Object>} from a plist xml file using
	 * the default mapping.
	 *
	 * @param file
	 *            the File containing the the plist xml.
	 * @return the resulting map as read from the plist data.
	 * @throws XmlParseException
	 *             if the plist could not be properly parsed.
	 * @throws IOException
	 *             if there was an issue reading the plist file.
	 */
	public static Dict load(File file) throws XmlParseException, IOException {
		FileInputStream byteStream = new FileInputStream(file);
		try {
			InputSource input = new InputSource(byteStream);
			return parse(input);
		} finally {
			silentlyClose(byteStream);
		}
	}

	/**
	 * Create a plist handler.
	 */
	PListParser() {
		this.m_dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
		this.m_dateFormat.setTimeZone(TimeZone.getTimeZone("Z"));
		this.m_simpleTypes = new HashMap<Class<?>, ElementType>();
		this.m_simpleTypes.put(Integer.class, ElementType.INTEGER);
		this.m_simpleTypes.put(Byte.class, ElementType.INTEGER);
		this.m_simpleTypes.put(Short.class, ElementType.INTEGER);
		this.m_simpleTypes.put(Short.class, ElementType.INTEGER);
		this.m_simpleTypes.put(Long.class, ElementType.INTEGER);
		this.m_simpleTypes.put(String.class, ElementType.STRING);
		this.m_simpleTypes.put(Float.class, ElementType.REAL);
		this.m_simpleTypes.put(Double.class, ElementType.REAL);
		this.m_simpleTypes.put(byte[].class, ElementType.DATA);
		this.m_simpleTypes.put(Boolean.class, ElementType.TRUE);
		this.m_simpleTypes.put(Date.class, ElementType.DATE);
	}

	/**
	 * Parses a plist top element into a map dictionary containing all the data
	 * in the plist.
	 *
	 * @param element
	 *            the top plist element.
	 * @return the resulting data tree structure.
	 * @throws XmlParseException
	 *             if there was any error parsing the xml.
	 */
	Dict parse(Element element) throws XmlParseException {
		if (!"plist".equalsIgnoreCase(element.getNodeName())) {
			throw new XmlParseException("Expected plist top element, was: " + element.getNodeName());
		}

		Node n = element.getFirstChild();
		while (n != null && !n.getNodeName().equals("dict")) {
			n = n.getNextSibling();
		}

		Dict result = (Dict) parseElement(n);
		return result;
	}

	/**
	 * Parses a (non-top) xml element.
	 *
	 * @param element
	 *            the element to parse.
	 * @return the resulting object.
	 * @throws XmlParseException
	 *             if there was some error in the xml.
	 */
	private Object parseElement(Node element) throws XmlParseException {
		try {
			return parseElementRaw(element);
		} catch (Exception e) {
			throw new XmlParseException("Failed to parse: " + element.getNodeName(), e);
		}
	}

	/**
	 * Parses a (non-top) xml element.
	 *
	 * @param element
	 *            the element to parse.
	 * @return the resulting object.
	 * @throws ParseException
	 *             if there was some error parsing the xml.
	 */
	private Object parseElementRaw(Node element) throws ParseException {
		ElementType type = ElementType.valueOf(element.getNodeName().toUpperCase());
		switch (type) {
		case INTEGER:
			return parseInt(getValue(element));
		case REAL:
			return Double.valueOf(getValue(element));
		case STRING:
			return getValue(element);
		case DATE:
			return this.m_dateFormat.parse(getValue(element));
		case DATA:
			return base64decode(getValue(element));
		case ARRAY:
			return parseArray(element.getChildNodes());
		case TRUE:
			return Boolean.TRUE;
		case FALSE:
			return Boolean.FALSE;
		case DICT:
			return parseDict(element.getChildNodes());
		default:
			throw new RuntimeException("Unexpected type: " + element.getNodeName());
		}
	}

	/*************************************************************************
	 * Parses a string value from a node.
	 * 
	 * @param n
	 *            the node to extract the value from.
	 * @return the content of the node
	 ************************************************************************/

	private String getValue(Node n) {
		StringBuilder sb = new StringBuilder();
		Node c = n.getFirstChild();
		while (c != null) {
			if (c.getNodeType() == Node.TEXT_NODE) {
				sb.append(c.getNodeValue());
			}
			c = c.getNextSibling();
		}
		return sb.toString();
	}

	/**
	 * Parses a string into a Long or Integer depending on size.
	 *
	 * @param value
	 *            the value as a string.
	 * @return the long value of this string is the value doesn't fit in an
	 *         integer, otherwise the int value of the string.
	 */
	private Number parseInt(String value) {
		Long l = Long.valueOf(value);
		if (l.intValue() == l) {
			return l.intValue();
		}
		return l;
	}

	/**
	 * Parse a list of xml elements as a plist dict.
	 *
	 * @param elements
	 *            the elements to parse.
	 * @return the dict deserialized as a map.
	 * @throws ParseException
	 *             if there are any problems deserializing the map.
	 */
	private Dict parseDict(NodeList elements) throws ParseException {
		Dict dict = new Dict();
		for (int i = 0; i < elements.getLength(); i++) {
			Node key = elements.item(i);
			if (key.getNodeType() != Node.ELEMENT_NODE) {
				continue;
			}
			if (!"key".equals(key.getNodeName())) {
				throw new ParseException("Expected key but was " + key.getNodeName(), -1);
			}
			i++;
			Node value = elements.item(i);
			while (value.getNodeType() != Node.ELEMENT_NODE) {
				i++;
				value = elements.item(i);
			}
			Object o = parseElementRaw(value);
			String dictName = getValue(key);
			dict.children.put(dictName, o);
		}
		return dict;
	}

	/**
	 * Parse a list of xml elements as a plist array.
	 *
	 * @param elements
	 *            the elements to parse.
	 * @return the array deserialized as a list.
	 * @throws ParseException
	 *             if there are any problems deserializing the list.
	 */
	private List<Object> parseArray(NodeList elements) throws ParseException {
		ArrayList<Object> list = new ArrayList<Object>();
		for (int i = 0; i < elements.getLength(); i++) {
			Node o = elements.item(i);
			if (o.getNodeType() != Node.ELEMENT_NODE) {
				continue;
			}
			list.add(parseElementRaw(o));
		}
		return list;
	}

	/**
	 * Encode an array of bytes to a string using base64 encoding.
	 *
	 * @param bytes
	 *            the bytes to convert.
	 * @return the base64 representation of the bytes.
	 */
	static String base64encode(byte[] bytes) {
		StringBuilder builder = new StringBuilder(((bytes.length + 2) / 3) * 4);
		for (int i = 0; i < bytes.length; i += 3) {
			byte b0 = bytes[i];
			byte b1 = i < bytes.length - 1 ? bytes[i + 1] : 0;
			byte b2 = i < bytes.length - 2 ? bytes[i + 2] : 0;
			builder.append(BASE64_CHARS[(b0 & 0xFF) >> 2]);
			builder.append(BASE64_CHARS[((b0 & 0x03) << 4) | ((b1 & 0xF0) >> 4)]);
			builder.append(i < bytes.length - 1 ? BASE64_CHARS[((b1 & 0x0F) << 2) | ((b2 & 0xC0) >> 6)] : "=");
			builder.append(i < bytes.length - 2 ? BASE64_CHARS[b2 & 0x3F] : "=");
		}
		return builder.toString();
	}

	/**
	 * Converts a string to a byte array assuming the string uses
	 * base64-encoding.
	 *
	 * @param base64
	 *            the string to convert.
	 * @return the resulting byte array.
	 */
	static byte[] base64decode(String base64) {
		base64 = base64.trim();
		int endTrim = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
		int length = (base64.length() / 4) * 3 - endTrim;
		base64 = base64.replace('=', 'A');
		byte[] result = new byte[length];
		int stringLength = base64.length();
		int index = 0;
		for (int i = 0; i < stringLength; i += 4) {
			int i0 = BASE64_STRING.indexOf(base64.charAt(i));
			int i1 = BASE64_STRING.indexOf(base64.charAt(i + 1));
			int i2 = BASE64_STRING.indexOf(base64.charAt(i + 2));
			int i3 = BASE64_STRING.indexOf(base64.charAt(i + 3));
			byte b0 = (byte) ((i0 << 2) | (i1 >> 4));
			byte b1 = (byte) ((i1 << 4) | (i2 >> 2));
			byte b2 = (byte) ((i2 << 6) | i3);
			result[index++] = b0;
			if (index < length) {
				result[index++] = b1;
				if (index < length) {
					result[index++] = b2;
				}
			}
		}
		return result;
	}

}
